snarkos_node_bft/helpers/
proposal.rs

1// Copyright (c) 2019-2025 Provable Inc.
2// This file is part of the snarkOS library.
3
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at:
7
8// http://www.apache.org/licenses/LICENSE-2.0
9
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16use snarkvm::{
17    console::{
18        account::{Address, Signature},
19        network::Network,
20        types::Field,
21    },
22    ledger::{
23        committee::Committee,
24        narwhal::{BatchCertificate, BatchHeader, Transmission, TransmissionID},
25    },
26    prelude::{FromBytes, IoResult, Itertools, Read, Result, ToBytes, Write, bail, ensure, error},
27};
28
29use indexmap::{IndexMap, IndexSet};
30use std::collections::HashSet;
31
32/// A pending proposal by a validator.
33///
34/// When a validator creates and broadcasts a proposal, it collects endorsing signatures from other validators.
35/// When the validator has enough endorsements, it turns the proposal into a certificate.
36/// This struct holds the information about the pending proposal, in the proposing validator's state,
37/// between the creation of the proposal and its turning into a certificate.
38///
39/// Invariant: `batch_header.transmission_ids()` contains the same elements as `transmissions.keys()`.
40/// [`Proposal::new`] and [`Proposal::read_le`] establish the invariant, by checking the condition;
41/// none of the other functions modifies the batch header or the transmissions.
42#[derive(Debug, PartialEq, Eq)]
43pub struct Proposal<N: Network> {
44    /// The proposed batch header.
45    batch_header: BatchHeader<N>,
46    /// The proposed transmissions.
47    transmissions: IndexMap<TransmissionID<N>, Transmission<N>>,
48    /// The set of endorsing signatures accumulated so far from other validators
49    /// (excludes the signature of the proposal author, which is in the `batch_header` component).
50    signatures: IndexSet<Signature<N>>,
51}
52
53impl<N: Network> Proposal<N> {
54    /// Initializes a new instance of the proposal.
55    ///
56    /// The `committee` input is the active (i.e. lookback) committee for the batch round.
57    /// A crucial protocol safety check made in this constructor is that the proposal author (the current validator),
58    /// is a member of that committee, because only members of the committee can propose batches.
59    pub fn new(
60        committee: Committee<N>,
61        batch_header: BatchHeader<N>,
62        transmissions: IndexMap<TransmissionID<N>, Transmission<N>>,
63    ) -> Result<Self> {
64        // Ensure the committee is for the batch round.
65        ensure!(batch_header.round() >= committee.starting_round(), "Batch round must be >= the committee round");
66        // Ensure the batch author is a member of the committee.
67        ensure!(committee.is_committee_member(batch_header.author()), "The batch author is not a committee member");
68        // Ensure the transmission IDs match in the batch header and transmissions.
69        ensure!(
70            batch_header.transmission_ids().len() == transmissions.len(),
71            "The transmission IDs do not match in the batch header and transmissions"
72        );
73        for (a, b) in batch_header.transmission_ids().iter().zip_eq(transmissions.keys()) {
74            ensure!(a == b, "The transmission IDs do not match in the batch header and transmissions");
75        }
76        // Return the proposal.
77        Ok(Self { batch_header, transmissions, signatures: Default::default() })
78    }
79
80    /// Returns the proposed batch header.
81    pub const fn batch_header(&self) -> &BatchHeader<N> {
82        &self.batch_header
83    }
84
85    /// Returns the proposed batch ID.
86    pub const fn batch_id(&self) -> Field<N> {
87        self.batch_header.batch_id()
88    }
89
90    /// Returns the round of the batch header.
91    pub const fn round(&self) -> u64 {
92        self.batch_header.round()
93    }
94
95    /// Returns the timestamp of the batch header.
96    pub const fn timestamp(&self) -> i64 {
97        self.batch_header.timestamp()
98    }
99
100    /// Returns the transmissions.
101    pub const fn transmissions(&self) -> &IndexMap<TransmissionID<N>, Transmission<N>> {
102        &self.transmissions
103    }
104
105    /// Returns the transmissions.
106    pub fn into_transmissions(self) -> IndexMap<TransmissionID<N>, Transmission<N>> {
107        self.transmissions
108    }
109
110    /// Returns all the signers, including author and endorsers.
111    pub fn signers(&self) -> HashSet<Address<N>> {
112        self.signatures.iter().chain(Some(self.batch_header.signature())).map(Signature::to_address).collect()
113    }
114
115    /// Returns the non-signers, i.e. the committee members that have not signed the proposal.
116    pub fn nonsigners(&self, committee: &Committee<N>) -> HashSet<Address<N>> {
117        // Retrieve the current signers.
118        let signers = self.signers();
119        // Initialize a set for the non-signers.
120        let mut nonsigners = HashSet::new();
121        // Iterate through the committee members.
122        for address in committee.members().keys() {
123            // Insert the address if it is not a signer.
124            if !signers.contains(address) {
125                nonsigners.insert(*address);
126            }
127        }
128        // Return the non-signers.
129        nonsigners
130    }
131
132    /// Returns `true` if the signers of the batch (author and endorsers)
133    /// have reached the quorum threshold for the committee.
134    pub fn is_quorum_threshold_reached(&self, committee: &Committee<N>) -> bool {
135        // Check if the batch has reached the quorum threshold.
136        committee.is_quorum_threshold_reached(&self.signers())
137    }
138
139    /// Returns `true` if the proposal contains the given transmission ID.
140    pub fn contains_transmission(&self, transmission_id: impl Into<TransmissionID<N>>) -> bool {
141        self.transmissions.contains_key(&transmission_id.into())
142    }
143
144    /// Returns the `transmission` for the given `transmission ID`.
145    pub fn get_transmission(&self, transmission_id: impl Into<TransmissionID<N>>) -> Option<&Transmission<N>> {
146        self.transmissions.get(&transmission_id.into())
147    }
148
149    /// Adds an endorsing signature to the proposal, if the signature is valid
150    /// and the signer is a committee member that has not already signed the proposal
151    /// (this implicitly checks that the signature is not from the author).
152    pub fn add_signature(
153        &mut self,
154        signer: Address<N>,
155        signature: Signature<N>,
156        committee: &Committee<N>,
157    ) -> Result<()> {
158        // Ensure the signer is in the committee.
159        if !committee.is_committee_member(signer) {
160            bail!("Signature from a non-committee member - '{signer}'")
161        }
162        // Ensure the signer is new.
163        if self.signers().contains(&signer) {
164            bail!("Duplicate signature from '{signer}'")
165        }
166        // Verify the signature. If the signature is not valid, return an error.
167        // Note: This check ensures the peer's address matches the address of the signature.
168        if !signature.verify(&signer, &[self.batch_id()]) {
169            bail!("Signature verification failed")
170        }
171        // Insert the signature.
172        self.signatures.insert(signature);
173        Ok(())
174    }
175
176    /// Returns the batch certificate and transmissions.
177    pub fn to_certificate(
178        &self,
179        committee: &Committee<N>,
180    ) -> Result<(BatchCertificate<N>, IndexMap<TransmissionID<N>, Transmission<N>>)> {
181        // Ensure the quorum threshold has been reached.
182        ensure!(self.is_quorum_threshold_reached(committee), "The quorum threshold has not been reached");
183        // Create the batch certificate.
184        let certificate = BatchCertificate::from(self.batch_header.clone(), self.signatures.clone())?;
185        // Return the certificate and transmissions.
186        Ok((certificate, self.transmissions.clone()))
187    }
188}
189
190impl<N: Network> ToBytes for Proposal<N> {
191    fn write_le<W: Write>(&self, mut writer: W) -> IoResult<()> {
192        // Write the batch header.
193        self.batch_header.write_le(&mut writer)?;
194        // Write the number of transmissions.
195        u32::try_from(self.transmissions.len()).map_err(error)?.write_le(&mut writer)?;
196        // Write the transmissions.
197        for (transmission_id, transmission) in &self.transmissions {
198            transmission_id.write_le(&mut writer)?;
199            transmission.write_le(&mut writer)?;
200        }
201        // Write the number of signatures.
202        u32::try_from(self.signatures.len()).map_err(error)?.write_le(&mut writer)?;
203        // Write the signatures.
204        for signature in &self.signatures {
205            signature.write_le(&mut writer)?;
206        }
207        Ok(())
208    }
209}
210
211impl<N: Network> FromBytes for Proposal<N> {
212    fn read_le<R: Read>(mut reader: R) -> IoResult<Self> {
213        // Read the batch header.
214        let batch_header: BatchHeader<N> = FromBytes::read_le(&mut reader)?;
215        // Read the number of transmissions.
216        let num_transmissions = u32::read_le(&mut reader)?;
217        // Ensure the number of transmissions is within bounds (this is an early safety check).
218        if num_transmissions as usize > BatchHeader::<N>::MAX_TRANSMISSIONS_PER_BATCH {
219            return Err(error("Invalid number of transmissions in the proposal"));
220        }
221        // Read the transmissions.
222        let mut transmissions = IndexMap::default();
223        for _ in 0..num_transmissions {
224            let transmission_id = FromBytes::read_le(&mut reader)?;
225            let transmission = FromBytes::read_le(&mut reader)?;
226            transmissions.insert(transmission_id, transmission);
227        }
228        // Read the number of signatures.
229        let num_signatures = u32::read_le(&mut reader)?;
230        // Ensure the number of signatures is within bounds (this is an early safety check).
231        if num_signatures as usize > Committee::<N>::max_committee_size().map_err(error)? as usize {
232            return Err(error("Invalid number of signatures in the proposal"));
233        }
234        // Read the signatures.
235        let mut signatures = IndexSet::default();
236        for _ in 0..num_signatures {
237            signatures.insert(FromBytes::read_le(&mut reader)?);
238        }
239
240        // Ensure the transmission IDs match in the batch header and transmissions.
241        if batch_header.transmission_ids().len() != transmissions.len() {
242            return Err(error("The transmission IDs do not match in the batch header and transmissions"));
243        }
244        for (a, b) in batch_header.transmission_ids().iter().zip_eq(transmissions.keys()) {
245            if a != b {
246                return Err(error("The transmission IDs do not match in the batch header and transmissions"));
247            }
248        }
249
250        Ok(Self { batch_header, transmissions, signatures })
251    }
252}
253
254#[cfg(test)]
255pub(crate) mod tests {
256    use super::*;
257    use crate::helpers::storage::tests::sample_transmissions;
258    use snarkvm::{console::network::MainnetV0, utilities::TestRng};
259
260    type CurrentNetwork = MainnetV0;
261
262    const ITERATIONS: usize = 100;
263
264    pub(crate) fn sample_proposal(rng: &mut TestRng) -> Proposal<CurrentNetwork> {
265        let certificate = snarkvm::ledger::narwhal::batch_certificate::test_helpers::sample_batch_certificate(rng);
266        let (_, transmissions) = sample_transmissions(&certificate, rng);
267
268        let transmissions =
269            certificate.transmission_ids().iter().map(|id| (*id, transmissions.get(id).unwrap().0.clone())).collect();
270
271        let batch_header = certificate.batch_header().clone();
272        let signatures = certificate.signatures().copied().collect();
273
274        Proposal { batch_header, transmissions, signatures }
275    }
276
277    #[test]
278    fn test_bytes() {
279        let rng = &mut TestRng::default();
280
281        for _ in 0..ITERATIONS {
282            let expected = sample_proposal(rng);
283            // Check the byte representation.
284            let expected_bytes = expected.to_bytes_le().unwrap();
285            assert_eq!(expected, Proposal::read_le(&expected_bytes[..]).unwrap());
286        }
287    }
288}
289
290#[cfg(test)]
291mod prop_tests {
292    use crate::helpers::{
293        Proposal,
294        now,
295        storage::prop_tests::{AnyTransmission, AnyTransmissionID, CryptoTestRng},
296    };
297    use snarkvm::ledger::{
298        committee::prop_tests::{CommitteeContext, ValidatorSet},
299        narwhal::BatchHeader,
300    };
301
302    use indexmap::IndexMap;
303    use proptest::sample::{Selector, size_range};
304    use test_strategy::proptest;
305
306    #[proptest]
307    fn initialize_proposal(
308        context: CommitteeContext,
309        #[any(size_range(1..16).lift())] transmissions: Vec<(AnyTransmissionID, AnyTransmission)>,
310        selector: Selector,
311        mut rng: CryptoTestRng,
312    ) {
313        let CommitteeContext(committee, ValidatorSet(validators)) = context;
314
315        let signer = selector.select(&validators);
316        let mut transmission_map = IndexMap::new();
317
318        for (AnyTransmissionID(id), AnyTransmission(t)) in transmissions.iter() {
319            transmission_map.insert(*id, t.clone());
320        }
321
322        let header = BatchHeader::new(
323            &signer.private_key,
324            committee.starting_round(),
325            now(),
326            committee.id(),
327            transmission_map.keys().cloned().collect(),
328            Default::default(),
329            &mut rng,
330        )
331        .unwrap();
332        let proposal = Proposal::new(committee, header.clone(), transmission_map.clone()).unwrap();
333        assert_eq!(proposal.batch_id(), header.batch_id());
334    }
335}