Skip to main content

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    ///
153    /// # Returns
154    ///  - `Ok(true)` for a new and valid signature.
155    ///  - `Ok(false)` for a valid but already existing signature.
156    ///  - `Err(err)` for invalid signatures and other errors.
157    pub fn add_signature(
158        &mut self,
159        signer: Address<N>,
160        signature: Signature<N>,
161        committee: &Committee<N>,
162    ) -> Result<bool> {
163        // Ensure the signer is in the committee.
164        if !committee.is_committee_member(signer) {
165            bail!("Signature from a non-committee member - '{signer}'")
166        }
167        // Ensure the signer is new.
168        if self.signers().contains(&signer) {
169            return Ok(false);
170        }
171        // Verify the signature. If the signature is not valid, return an error.
172        // Note: This check ensures the peer's address matches the address of the signature.
173        if !signature.verify(&signer, &[self.batch_id()]) {
174            bail!("Signature verification failed")
175        }
176
177        // Insert the new signature and return success.
178        self.signatures.insert(signature);
179        Ok(true)
180    }
181
182    /// Returns the batch certificate and transmissions.
183    pub fn to_certificate(
184        &self,
185        committee: &Committee<N>,
186    ) -> Result<(BatchCertificate<N>, IndexMap<TransmissionID<N>, Transmission<N>>)> {
187        // Ensure the quorum threshold has been reached.
188        ensure!(self.is_quorum_threshold_reached(committee), "The quorum threshold has not been reached");
189        // Create the batch certificate.
190        let certificate = BatchCertificate::from(self.batch_header.clone(), self.signatures.clone())?;
191        // Return the certificate and transmissions.
192        Ok((certificate, self.transmissions.clone()))
193    }
194}
195
196impl<N: Network> ToBytes for Proposal<N> {
197    fn write_le<W: Write>(&self, mut writer: W) -> IoResult<()> {
198        // Write the batch header.
199        self.batch_header.write_le(&mut writer)?;
200        // Write the number of transmissions.
201        u32::try_from(self.transmissions.len()).map_err(error)?.write_le(&mut writer)?;
202        // Write the transmissions.
203        for (transmission_id, transmission) in &self.transmissions {
204            transmission_id.write_le(&mut writer)?;
205            transmission.write_le(&mut writer)?;
206        }
207        // Write the number of signatures.
208        u32::try_from(self.signatures.len()).map_err(error)?.write_le(&mut writer)?;
209        // Write the signatures.
210        for signature in &self.signatures {
211            signature.write_le(&mut writer)?;
212        }
213        Ok(())
214    }
215}
216
217impl<N: Network> FromBytes for Proposal<N> {
218    fn read_le<R: Read>(mut reader: R) -> IoResult<Self> {
219        // Read the batch header.
220        let batch_header: BatchHeader<N> = FromBytes::read_le(&mut reader)?;
221        // Read the number of transmissions.
222        let num_transmissions = u32::read_le(&mut reader)?;
223        // Ensure the number of transmissions is within bounds (this is an early safety check).
224        if num_transmissions as usize > BatchHeader::<N>::MAX_TRANSMISSIONS_PER_BATCH {
225            return Err(error("Invalid number of transmissions in the proposal"));
226        }
227        // Read the transmissions.
228        let mut transmissions = IndexMap::default();
229        for _ in 0..num_transmissions {
230            let transmission_id = FromBytes::read_le(&mut reader)?;
231            let transmission = FromBytes::read_le(&mut reader)?;
232            transmissions.insert(transmission_id, transmission);
233        }
234        // Read the number of signatures.
235        let num_signatures = u32::read_le(&mut reader)?;
236        // Ensure the number of signatures is within bounds (this is an early safety check).
237        if num_signatures as usize > Committee::<N>::max_committee_size().map_err(error)? as usize {
238            return Err(error("Invalid number of signatures in the proposal"));
239        }
240        // Read the signatures.
241        let mut signatures = IndexSet::default();
242        for _ in 0..num_signatures {
243            signatures.insert(FromBytes::read_le(&mut reader)?);
244        }
245
246        // Ensure the transmission IDs match in the batch header and transmissions.
247        if batch_header.transmission_ids().len() != transmissions.len() {
248            return Err(error("The transmission IDs do not match in the batch header and transmissions"));
249        }
250        for (a, b) in batch_header.transmission_ids().iter().zip_eq(transmissions.keys()) {
251            if a != b {
252                return Err(error("The transmission IDs do not match in the batch header and transmissions"));
253            }
254        }
255
256        Ok(Self { batch_header, transmissions, signatures })
257    }
258}
259
260#[cfg(test)]
261pub(crate) mod tests {
262    use super::*;
263    use crate::helpers::storage::tests::sample_transmissions;
264    use snarkvm::{console::network::MainnetV0, utilities::TestRng};
265
266    type CurrentNetwork = MainnetV0;
267
268    const ITERATIONS: usize = 100;
269
270    pub(crate) fn sample_proposal(rng: &mut TestRng) -> Proposal<CurrentNetwork> {
271        let certificate = snarkvm::ledger::narwhal::batch_certificate::test_helpers::sample_batch_certificate(rng);
272        let (_, transmissions) = sample_transmissions(&certificate, rng);
273
274        let transmissions =
275            certificate.transmission_ids().iter().map(|id| (*id, transmissions.get(id).unwrap().0.clone())).collect();
276
277        let batch_header = certificate.batch_header().clone();
278        let signatures = certificate.signatures().copied().collect();
279
280        Proposal { batch_header, transmissions, signatures }
281    }
282
283    #[test]
284    fn test_bytes() {
285        let rng = &mut TestRng::default();
286
287        for _ in 0..ITERATIONS {
288            let expected = sample_proposal(rng);
289            // Check the byte representation.
290            let expected_bytes = expected.to_bytes_le().unwrap();
291            assert_eq!(expected, Proposal::read_le(&expected_bytes[..]).unwrap());
292        }
293    }
294}
295
296#[cfg(test)]
297mod prop_tests {
298    use crate::helpers::{
299        Proposal,
300        now,
301        storage::prop_tests::{AnyTransmission, AnyTransmissionID, CryptoTestRng},
302    };
303    use snarkvm::ledger::{
304        committee::prop_tests::{CommitteeContext, ValidatorSet},
305        narwhal::BatchHeader,
306    };
307
308    use indexmap::IndexMap;
309    use proptest::sample::{Selector, size_range};
310    use test_strategy::proptest;
311
312    #[proptest]
313    fn initialize_proposal(
314        context: CommitteeContext,
315        #[any(size_range(1..16).lift())] transmissions: Vec<(AnyTransmissionID, AnyTransmission)>,
316        selector: Selector,
317        mut rng: CryptoTestRng,
318    ) {
319        let CommitteeContext(committee, ValidatorSet(validators)) = context;
320
321        let signer = selector.select(&validators);
322        let mut transmission_map = IndexMap::new();
323
324        for (AnyTransmissionID(id), AnyTransmission(t)) in transmissions.iter() {
325            transmission_map.insert(*id, t.clone());
326        }
327
328        let header = BatchHeader::new(
329            &signer.private_key,
330            committee.starting_round(),
331            now(),
332            committee.id(),
333            transmission_map.keys().cloned().collect(),
334            Default::default(),
335            &mut rng,
336        )
337        .unwrap();
338        let proposal = Proposal::new(committee, header.clone(), transmission_map.clone()).unwrap();
339        assert_eq!(proposal.batch_id(), header.batch_id());
340    }
341}