Skip to main content

linera_client/
lite_client.rs

1// Copyright (c) Zefchain Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! A storage-free client that proposes blocks directly to validators.
5//!
6//! Unlike [`ChainClient`](linera_core::client::chain_client::ChainClient) this keeps no
7//! local storage and executes nothing: it tracks just enough chain state to keep
8//! proposing valid blocks. That makes it cheap enough that a load generator stops being
9//! part of what a benchmark measures, at the cost of three round trips per block instead
10//! of two.
11
12use std::{
13    collections::{BTreeMap, HashMap, HashSet},
14    sync::Arc,
15};
16
17use anyhow::{anyhow, bail, Context as _, Result};
18use futures::future::join_all;
19use linera_base::{
20    crypto::{CryptoHash, ValidatorPublicKey, ValidatorSignature},
21    data_types::{Epoch, Round, Timestamp},
22    identifiers::{AccountOwner, ChainId},
23};
24use linera_cache::ValueCache;
25use linera_chain::{
26    data_types::{BlockProposal, IncomingBundle, ProposedBlock, Transaction},
27    justification::JustificationChain,
28    types::{
29        CertificateKind, CertificateValue as _, ConfirmedBlock, ConfirmedBlockCertificate,
30        GenericCertificate,
31    },
32};
33use linera_core::{
34    client::Client as CoreClient,
35    data_types::ChainInfoQuery,
36    environment::Environment,
37    node::{CrossChainMessageDelivery, ValidatorNode},
38    remote_node::RemoteNode,
39};
40use linera_execution::{committee::Committee, Operation};
41use linera_rpc::Client;
42use tokio::sync::Mutex;
43use tracing::warn;
44
45use crate::benchmark::{BenchmarkClient, BenchmarkError};
46
47/// Tracks just enough state about one chain to keep proposing valid blocks, without any
48/// local storage or execution.
49pub struct LiteChainClient<Env: Environment> {
50    chain_id: ChainId,
51    owner: AccountOwner,
52    epoch: Epoch,
53    height: linera_base::data_types::BlockHeight,
54    previous_block_hash: Option<CryptoHash>,
55    nodes: Vec<(ValidatorPublicKey, Client)>,
56    committee: Committee,
57    /// Shared with every other chain this process drives; only its signer is used.
58    client: Arc<CoreClient<Env>>,
59    value_cache: ValueCache<CryptoHash, ConfirmedBlockCertificate>,
60    /// The incoming message bundles to drain into the *next* block, computed as a side effect
61    /// of the previous block's confirmed-value fetch (see `propose_and_commit`). Held across
62    /// blocks so that draining costs no extra round trip; empty before the first block and in
63    /// `independent` mode.
64    pending_bundles: Vec<IncomingBundle>,
65    /// Whether to broadcast the confirmed certificate in its compact, value-free form where
66    /// possible (see `--light-certificates`).
67    light_certificates: bool,
68}
69
70impl<Env: Environment> LiteChainClient<Env> {
71    /// Seeds the client's state for `chain_id` from the first validator that answers.
72    pub async fn seed(
73        chain_id: ChainId,
74        owner: AccountOwner,
75        nodes: Vec<(ValidatorPublicKey, Client)>,
76        committee: Committee,
77        client: Arc<CoreClient<Env>>,
78        light_certificates: bool,
79    ) -> Result<Self> {
80        for (public_key, node) in &nodes {
81            let query = ChainInfoQuery::new(chain_id);
82            match node.handle_chain_info_query(query).await {
83                Ok(response) => {
84                    let info = response.info;
85                    return Ok(Self {
86                        chain_id,
87                        owner,
88                        epoch: info.epoch,
89                        height: info.next_block_height,
90                        previous_block_hash: info.block_hash,
91                        nodes,
92                        committee,
93                        client,
94                        value_cache: ValueCache::new("lite-benchmark", 64, 60),
95                        pending_bundles: Vec::new(),
96                        light_certificates,
97                    });
98                }
99                Err(error) => {
100                    warn!(%public_key, %error, "validator did not answer the initial chain info query");
101                }
102            }
103        }
104        bail!("no validator answered the initial chain info query");
105    }
106
107    /// Builds, signs, and submits a block with the given operations, then drives it to a
108    /// committed certificate. Uses `Round::Fast`, so this only works on chains owned by a
109    /// single super owner.
110    ///
111    /// If `process_messages` is set, the block first drains up to `bundle_cap` incoming message
112    /// bundles from this chain's inboxes (as `Transaction::ReceiveMessages`, before the
113    /// operations), so the inboxes don't grow without bound in cross-chain traffic modes.
114    /// Returns the number of bundles that were included.
115    ///
116    /// The bundles come from `self.pending_bundles`, which the *previous* block's confirmed-value
117    /// fetch computed for us -- so draining costs no extra round trip. This is sound because a
118    /// block only removes its bundles from the validators' inboxes once it commits: the bundles
119    /// we carried over are still pending (nothing else drains this chain), and still present in
120    /// the validators that reported them, so the proposal is accepted. `self.pending_bundles` is
121    /// only refreshed after this block commits, so a failed block simply retries the same set.
122    pub async fn propose_and_commit(
123        &mut self,
124        operations: Vec<linera_execution::Operation>,
125        process_messages: bool,
126        bundle_cap: usize,
127    ) -> Result<usize> {
128        let bundles: Vec<IncomingBundle> = if process_messages {
129            self.pending_bundles
130                .iter()
131                .take(bundle_cap)
132                .cloned()
133                .collect()
134        } else {
135            Vec::new()
136        };
137        let num_bundles = bundles.len();
138        // The bundles this block consumes, so the next block's pending set can exclude them (the
139        // confirmed-value fetch below sees them still in the inboxes, since our certificate has
140        // not been broadcast yet).
141        let consumed: HashSet<_> = bundles
142            .iter()
143            .map(|bundle| (bundle.origin, bundle.bundle.cursor()))
144            .collect();
145        let transactions = bundles
146            .into_iter()
147            .map(Transaction::ReceiveMessages)
148            .chain(operations.into_iter().map(Transaction::ExecuteOperation))
149            .collect();
150        let block = ProposedBlock {
151            chain_id: self.chain_id,
152            epoch: self.epoch,
153            transactions,
154            height: self.height,
155            timestamp: Timestamp::now(),
156            authenticated_owner: Some(self.owner),
157            previous_block_hash: self.previous_block_hash,
158        };
159        let proposal =
160            BlockProposal::new_initial(self.owner, Round::Fast, block, self.client.signer())
161                .await
162                .map_err(|error| anyhow!("failed to sign the block proposal: {error}"))?;
163
164        // Broadcast the proposal to every validator and collect their `ConfirmedBlock` votes.
165        let responses = join_all(self.nodes.iter().map(|(public_key, node)| {
166            let proposal = proposal.clone();
167            let public_key = *public_key;
168            let node = node.clone();
169            async move { (public_key, node.handle_block_proposal(proposal).await) }
170        }))
171        .await;
172        let votes = responses
173            .into_iter()
174            .filter_map(|(public_key, result)| match result {
175                Ok(response) => response.info.manager.pending.map(|vote| (public_key, vote)),
176                Err(error) => {
177                    warn!(%public_key, %error, "validator rejected the block proposal");
178                    None
179                }
180            });
181        let (value_hash, signatures) =
182            find_confirming_quorum(self.chain_id, votes, &self.committee)
183                .context("no quorum of validators voted to confirm the proposed block")?;
184
185        // Fetch the confirmed value (with its real execution outcome) instead of executing the
186        // block ourselves, and -- folded into the same round trip -- the inboxes' pending
187        // bundles, from which we compute the set to drain into the *next* block.
188        let (confirmed_block, next_pending) = self
189            .fetch_confirmed_and_pending(value_hash, process_messages, &consumed)
190            .await?;
191
192        // The vote's `first_round` attestation must be reproduced exactly, since it is part of
193        // what every signature covers (see `Vote::new_with_first_round`); a single super owner's
194        // `Round::Fast` is always the chain's designated first round, so this is always `true`.
195        let quorum = GenericCertificate::new_with_payload(
196            confirmed_block,
197            Round::Fast,
198            None,
199            true,
200            None,
201            signatures,
202        );
203        let certificate =
204            ConfirmedBlockCertificate::from_parts(quorum, JustificationChain::default());
205        // Hoisted so the certificate can be moved into the cache: cloning it here deep-copies
206        // the whole confirmed block on every single block.
207        let certificate_hash = certificate.hash();
208        let cached_certificate = self.value_cache.insert(&certificate_hash, certificate);
209
210        // Broadcast the certificate so every validator commits the block. Only advance our own
211        // state once at least one validator actually accepted it, so we don't get out of sync
212        // with the chain if the certificate is rejected everywhere.
213        //
214        // With --light-certificates, prefer sending each validator just the certificate's hash
215        // and signatures (no block value) via RemoteNode::handle_optimized_confirmed_certificate
216        // -- every validator here voted on this block in the first round trip, so it already has
217        // the value cached and can reconstruct the full certificate locally. A validator that
218        // fell behind and forgot the value it signed gets a transparent fallback to the full
219        // certificate (see that method's doc comment). This only shrinks this round trip's
220        // payload; it doesn't remove it.
221        let light_certificates = self.light_certificates;
222        let results = join_all(self.nodes.iter().map(|(public_key, node)| {
223            let node = node.clone();
224            let cached_certificate = cached_certificate.clone();
225            async move {
226                if light_certificates {
227                    let remote_node = RemoteNode {
228                        public_key: *public_key,
229                        node,
230                    };
231                    remote_node
232                        .handle_optimized_confirmed_certificate(
233                            &cached_certificate,
234                            CrossChainMessageDelivery::NonBlocking,
235                        )
236                        .await
237                        .map(|_| ())
238                } else {
239                    node.handle_confirmed_certificate(
240                        cached_certificate,
241                        CrossChainMessageDelivery::NonBlocking,
242                    )
243                    .await
244                    .map(|_| ())
245                }
246            }
247        }))
248        .await;
249        let mut committed = false;
250        for result in results {
251            if let Err(error) = result {
252                warn!(%error, "validator failed to process the confirmed certificate");
253            } else {
254                committed = true;
255            }
256        }
257        anyhow::ensure!(committed, "no validator accepted the confirmed certificate");
258
259        self.previous_block_hash = Some(certificate_hash);
260        self.height = self.height.try_add_one()?;
261        // Only now that the block committed (so its bundles are being removed from the inboxes)
262        // do we adopt the next pending set. On a failed block we keep `self.pending_bundles` as
263        // it was, so the next attempt retries the same, still-pending bundles.
264        self.pending_bundles = next_pending;
265        Ok(num_bundles)
266    }
267
268    /// In one parallel round trip to every validator, fetches the confirmed block value for
269    /// `value_hash` (from any validator that has it) and, if `process_messages` is set, the
270    /// bundles to drain into the *next* block.
271    ///
272    /// The next pending set is the per-origin prefix that *every* responding validator agrees
273    /// on, minus `consumed` (the bundles this block is about to remove, which are still in the
274    /// inboxes at query time since our certificate has not been broadcast yet). We take only the
275    /// agreed prefix because certificates are delivered non-blocking, so the validators' inboxes
276    /// are not in lockstep: a bundle one validator already holds may not have reached another. A
277    /// proposal is rejected wholesale if it receives a bundle a validator lacks
278    /// (`MissingCrossChainUpdate`), and a given origin's bundles must be consumed in cursor order
279    /// (`IncorrectOrder`), so anything not yet everywhere is simply left for a later block. No
280    /// validator response is trusted for anything but which bundles exist; they are copied
281    /// verbatim into the block. The result is not capped here -- the cap is applied when the
282    /// bundles are actually included, so a backlog beyond one block's cap carries forward.
283    async fn fetch_confirmed_and_pending(
284        &self,
285        value_hash: CryptoHash,
286        process_messages: bool,
287        consumed: &HashSet<(ChainId, linera_base::data_types::Cursor)>,
288    ) -> Result<(ConfirmedBlock, Vec<IncomingBundle>)> {
289        let responses = join_all(self.nodes.iter().map(|(public_key, node)| {
290            let node = node.clone();
291            let mut query = ChainInfoQuery::new(self.chain_id);
292            // Only `manager.requested_confirmed` is read below, but the flag is all-or-
293            // nothing on the wire (`add_values` also attaches the proposed and locking
294            // blocks), so each validator returns roughly two extra block-sized payloads per
295            // block. Narrowing it needs a new query field, not a change here.
296            query.request_manager_values = true;
297            if process_messages {
298                query = query.with_pending_message_bundles();
299            }
300            let public_key = *public_key;
301            async move {
302                match node.handle_chain_info_query(query).await {
303                    Ok(response) => Some(response.info),
304                    Err(error) => {
305                        warn!(%public_key, %error, "validator did not answer the confirmed-value query");
306                        None
307                    }
308                }
309            }
310        }))
311        .await;
312
313        let mut confirmed_block: Option<ConfirmedBlock> = None;
314        let mut per_node: Vec<Vec<IncomingBundle>> = Vec::new();
315        for info in responses.into_iter().flatten() {
316            if process_messages {
317                per_node.push(info.requested_pending_message_bundles);
318            }
319            if confirmed_block.is_none() {
320                if let Some(value) = info.manager.requested_confirmed {
321                    if value.hash() == value_hash {
322                        confirmed_block = Some(*value);
323                    }
324                }
325            }
326        }
327        let confirmed_block =
328            confirmed_block.context("could not fetch the confirmed block value")?;
329
330        let next_pending = if process_messages {
331            common_prefix_bundles(per_node)
332                .into_iter()
333                .filter(|bundle| !consumed.contains(&(bundle.origin, bundle.bundle.cursor())))
334                .collect()
335        } else {
336            Vec::new()
337        };
338        Ok((confirmed_block, next_pending))
339    }
340}
341
342/// Given each responding validator's list of pending incoming bundles, returns the bundles that
343/// appear -- as an in-order per-origin prefix -- in *every* list. Bundles from one origin are
344/// FIFO by cursor, so for each origin this compares the lists element by element and keeps the
345/// longest common leading run; an origin missing from any list contributes nothing. Origins are
346/// visited in a deterministic (sorted) order. See `fetch_confirmed_and_pending` for why only this
347/// safe intersection is used.
348fn common_prefix_bundles(per_node: Vec<Vec<IncomingBundle>>) -> Vec<IncomingBundle> {
349    let Some((first, rest)) = per_node.split_first() else {
350        return Vec::new();
351    };
352    // Group each node's bundles by origin, preserving each origin's cursor order.
353    let group = |bundles: &[IncomingBundle]| -> BTreeMap<ChainId, Vec<IncomingBundle>> {
354        let mut by_origin: BTreeMap<ChainId, Vec<IncomingBundle>> = BTreeMap::new();
355        for bundle in bundles {
356            by_origin
357                .entry(bundle.origin)
358                .or_default()
359                .push(bundle.clone());
360        }
361        by_origin
362    };
363    let base = group(first);
364    let others: Vec<_> = rest.iter().map(|node| group(node)).collect();
365    let mut result = Vec::new();
366    for (origin, base_bundles) in base {
367        let mut prefix_len = base_bundles.len();
368        for other in &others {
369            let other_bundles = other.get(&origin).map_or(&[][..], Vec::as_slice);
370            let matching = base_bundles
371                .iter()
372                .zip(other_bundles)
373                .take_while(|(a, b)| a.bundle.cursor() == b.bundle.cursor())
374                .count();
375            prefix_len = prefix_len.min(matching);
376            if prefix_len == 0 {
377                break;
378            }
379        }
380        result.extend(base_bundles.into_iter().take(prefix_len));
381    }
382    result
383}
384
385/// Groups the given validator votes by the `ConfirmedBlock` value hash they attest to, and
386/// returns the first hash (and its signatures) whose combined committee weight reaches the
387/// quorum threshold. Votes for the wrong chain or of the wrong kind are ignored. No signature
388/// is verified here: the caller trusts every vote at face value.
389fn find_confirming_quorum(
390    chain_id: ChainId,
391    votes: impl IntoIterator<Item = (ValidatorPublicKey, linera_chain::data_types::LiteVote)>,
392    committee: &Committee,
393) -> Option<(CryptoHash, Vec<(ValidatorPublicKey, ValidatorSignature)>)> {
394    let mut signatures_by_hash: HashMap<CryptoHash, Vec<(ValidatorPublicKey, ValidatorSignature)>> =
395        HashMap::new();
396    let mut weight_by_hash: HashMap<CryptoHash, u64> = HashMap::new();
397    for (public_key, vote) in votes {
398        if vote.value.chain_id != chain_id || vote.value.kind != CertificateKind::Confirmed {
399            continue;
400        }
401        let hash = vote.value.value_hash;
402        signatures_by_hash
403            .entry(hash)
404            .or_default()
405            .push((public_key, vote.signature));
406        let weight = weight_by_hash.entry(hash).or_insert(0);
407        *weight += committee.weight(&public_key);
408        if *weight >= committee.quorum_threshold() {
409            let signatures = signatures_by_hash
410                .remove(&hash)
411                .expect("just inserted above");
412            return Some((hash, signatures));
413        }
414    }
415    None
416}
417
418/// Adapts [`LiteChainClient`] to the shared benchmark harness.
419///
420/// The harness holds each client behind a shared reference and drives one chain per task, so
421/// the mutable proposal state (height, previous hash, pending bundles) sits behind a mutex
422/// that is only ever contended if a caller drives the same chain from two places -- which
423/// would be a bug regardless, since block heights are sequential.
424pub struct LiteBenchmarkClient<Env: Environment> {
425    chain_id: ChainId,
426    owner: AccountOwner,
427    inner: Mutex<LiteChainClient<Env>>,
428    process_messages: bool,
429    bundle_cap: Option<usize>,
430}
431
432impl<Env: Environment> LiteBenchmarkClient<Env> {
433    /// Wraps a seeded client. `bundle_cap` defaults to twice the block's operation count, so a
434    /// backlog is drained over several blocks rather than one oversized one.
435    pub fn new(
436        client: LiteChainClient<Env>,
437        process_messages: bool,
438        bundle_cap: Option<usize>,
439    ) -> Self {
440        Self {
441            chain_id: client.chain_id,
442            owner: client.owner,
443            inner: Mutex::new(client),
444            process_messages,
445            bundle_cap,
446        }
447    }
448}
449
450#[async_trait::async_trait]
451impl<Env: Environment> BenchmarkClient for LiteBenchmarkClient<Env> {
452    fn chain_id(&self) -> ChainId {
453        self.chain_id
454    }
455
456    async fn owner(&self) -> Result<AccountOwner, BenchmarkError> {
457        Ok(self.owner)
458    }
459
460    async fn commit_operations(&self, operations: Vec<Operation>) -> Result<(), BenchmarkError> {
461        let bundle_cap = self
462            .bundle_cap
463            .unwrap_or_else(|| operations.len().saturating_mul(2));
464        self.inner
465            .lock()
466            .await
467            .propose_and_commit(operations, self.process_messages, bundle_cap)
468            .await
469            .map_err(|error| BenchmarkError::LiteClient(error.to_string()))?;
470        Ok(())
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use linera_base::{
477        crypto::{AccountSecretKey, CryptoHash, ValidatorKeypair},
478        data_types::BlockHeight,
479    };
480    use linera_chain::data_types::{LiteValue, LiteVote, MessageAction, MessageBundle};
481
482    use super::*;
483
484    /// A pending bundle from `origin` whose cursor is `(height, index)`. The message list is
485    /// empty: `common_prefix_bundles` compares only cursors, so the contents are irrelevant.
486    fn bundle(origin: ChainId, height: u64, index: u32) -> IncomingBundle {
487        IncomingBundle {
488            origin,
489            bundle: MessageBundle {
490                height: BlockHeight(height),
491                timestamp: Timestamp::from(0),
492                certificate_hash: CryptoHash::test_hash("cert"),
493                transaction_index: index,
494                messages: Vec::new(),
495            },
496            action: MessageAction::Accept,
497        }
498    }
499
500    /// The bundles' cursors, sorted by (origin, height, index). Sorting makes comparisons
501    /// insensitive to the order origins are emitted in (which is irrelevant, since each origin's
502    /// inbox is drained independently) while still exposing any per-origin reordering, because
503    /// within an origin the expected cursors are already ascending.
504    fn cursors(bundles: &[IncomingBundle]) -> Vec<(ChainId, u64, u32)> {
505        let mut cursors: Vec<_> = bundles
506            .iter()
507            .map(|b| (b.origin, b.bundle.height.0, b.bundle.transaction_index))
508            .collect();
509        cursors.sort();
510        cursors
511    }
512
513    fn sorted(mut cursors: Vec<(ChainId, u64, u32)>) -> Vec<(ChainId, u64, u32)> {
514        cursors.sort();
515        cursors
516    }
517
518    #[test]
519    fn common_prefix_takes_the_agreed_per_origin_prefix() {
520        let a = ChainId(CryptoHash::test_hash("a"));
521        let b = ChainId(CryptoHash::test_hash("b"));
522
523        // No responders at all -> nothing to drain.
524        assert!(common_prefix_bundles(Vec::new()).is_empty());
525
526        // A single responder: everything it lists is included (grouped by origin, in order).
527        let only = vec![bundle(a, 0, 0), bundle(a, 1, 0), bundle(b, 0, 0)];
528        assert_eq!(
529            cursors(&common_prefix_bundles(vec![only.clone()])),
530            sorted(vec![(a, 0, 0), (a, 1, 0), (b, 0, 0)]),
531        );
532
533        // Two responders agreeing fully: the whole thing survives.
534        assert_eq!(
535            common_prefix_bundles(vec![only.clone(), only.clone()]).len(),
536            3
537        );
538
539        // One responder is one bundle behind on origin `a`: only the shared prefix of `a`
540        // survives, and origin `b`, present in both, is kept.
541        let ahead = vec![bundle(a, 0, 0), bundle(a, 1, 0), bundle(b, 0, 0)];
542        let behind = vec![bundle(a, 0, 0), bundle(b, 0, 0)];
543        assert_eq!(
544            cursors(&common_prefix_bundles(vec![ahead, behind])),
545            sorted(vec![(a, 0, 0), (b, 0, 0)]),
546        );
547
548        // The lists diverge mid-origin (a different cursor at index 1): the prefix stops at the
549        // divergence, and nothing past it is included even though later cursors happen to match.
550        let left = vec![bundle(a, 0, 0), bundle(a, 1, 0), bundle(a, 2, 0)];
551        let right = vec![bundle(a, 0, 0), bundle(a, 5, 0), bundle(a, 2, 0)];
552        assert_eq!(
553            cursors(&common_prefix_bundles(vec![left, right])),
554            vec![(a, 0, 0)],
555        );
556
557        // An origin missing from one responder contributes nothing, but other shared origins
558        // are unaffected.
559        let with_b = vec![bundle(a, 0, 0), bundle(b, 0, 0)];
560        let without_b = vec![bundle(a, 0, 0)];
561        assert_eq!(
562            cursors(&common_prefix_bundles(vec![with_b, without_b])),
563            vec![(a, 0, 0)],
564        );
565    }
566
567    fn committee_of(size: usize) -> (Committee, Vec<ValidatorPublicKey>) {
568        let keys: Vec<_> = (0..size)
569            .map(|_| {
570                (
571                    ValidatorKeypair::generate().public_key,
572                    AccountSecretKey::generate().public(),
573                )
574            })
575            .collect();
576        let public_keys = keys.iter().map(|(key, _)| *key).collect();
577        (Committee::make_simple(keys), public_keys)
578    }
579
580    fn vote(chain_id: ChainId, value_hash: CryptoHash) -> LiteVote {
581        LiteVote {
582            value: LiteValue {
583                value_hash,
584                chain_id,
585                kind: CertificateKind::Confirmed,
586            },
587            round: Round::Fast,
588            unlocking_round: None,
589            first_round: true,
590            justification_commitment: None,
591            signature: ValidatorSignature::sign_prehash(
592                &ValidatorKeypair::generate().secret_key,
593                value_hash,
594            ),
595        }
596    }
597
598    #[test]
599    fn quorum_is_reached_once_enough_weight_agrees() {
600        let chain_id = ChainId(CryptoHash::test_hash("chain"));
601        let value_hash = CryptoHash::test_hash("confirmed-block");
602        let (committee, keys) = committee_of(4);
603
604        // Only 2 out of 4 equally-weighted validators agree: not a quorum yet.
605        let votes = keys[..2]
606            .iter()
607            .map(|key| (*key, vote(chain_id, value_hash)));
608        assert!(find_confirming_quorum(chain_id, votes, &committee).is_none());
609
610        // 3 out of 4 is enough.
611        let votes = keys[..3]
612            .iter()
613            .map(|key| (*key, vote(chain_id, value_hash)));
614        let (hash, signatures) = find_confirming_quorum(chain_id, votes, &committee)
615            .expect("3 out of 4 equally-weighted validators should reach the quorum threshold");
616        assert_eq!(hash, value_hash);
617        assert_eq!(signatures.len(), 3);
618    }
619
620    #[test]
621    fn votes_for_a_different_chain_are_ignored() {
622        let chain_id = ChainId(CryptoHash::test_hash("chain"));
623        let other_chain_id = ChainId(CryptoHash::test_hash("other-chain"));
624        let value_hash = CryptoHash::test_hash("confirmed-block");
625        let (committee, keys) = committee_of(4);
626
627        let votes = keys
628            .iter()
629            .map(|key| (*key, vote(other_chain_id, value_hash)));
630        assert!(find_confirming_quorum(chain_id, votes, &committee).is_none());
631    }
632
633    #[test]
634    fn a_split_vote_never_reaches_quorum_on_either_side() {
635        let chain_id = ChainId(CryptoHash::test_hash("chain"));
636        let hash_a = CryptoHash::test_hash("block-a");
637        let hash_b = CryptoHash::test_hash("block-b");
638        let (committee, keys) = committee_of(4);
639
640        let votes = vec![
641            (keys[0], vote(chain_id, hash_a)),
642            (keys[1], vote(chain_id, hash_a)),
643            (keys[2], vote(chain_id, hash_b)),
644            (keys[3], vote(chain_id, hash_b)),
645        ];
646        assert!(find_confirming_quorum(chain_id, votes, &committee).is_none());
647    }
648}