mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
merge: adopt vote.rs + zap.rs into the canonical crate (lux-rs fork retirement); Tally derives its quorum from the set
Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
Generated
+1
-1
@@ -408,7 +408,7 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "lux-consensus"
|
||||
version = "1.22.6"
|
||||
version = "1.22.7"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"blst",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lux-consensus"
|
||||
version = "1.22.6"
|
||||
version = "1.22.7"
|
||||
edition = "2021"
|
||||
description = "Lux Consensus SDK — pure-Rust Quasar post-quantum probabilistic consensus (Wave / FPC / Photon / Focus / Quasar PQ finality via hybrid BLS12-381 + Pulsar = P3Q)"
|
||||
license-file = "LICENSE"
|
||||
|
||||
@@ -313,7 +313,13 @@ impl QuorumCert {
|
||||
/// The count-only predicate: every structural clause, plus a valid signature
|
||||
/// from each of at least `threshold` distinct voters.
|
||||
///
|
||||
/// This is NOT a standalone accept rule, and must not be used as one. The
|
||||
/// A CERTIFICATE IS A RECORD, NOT A CAPABILITY. Every field on it is public
|
||||
/// and anyone may build one that says anything; holding one authorises
|
||||
/// nothing. What authorises is [`Self::verify_weighted`], and EVERY acceptor
|
||||
/// runs it — this, the count-only predicate, is the structural pre-filter
|
||||
/// inside it.
|
||||
///
|
||||
/// So it is NOT a standalone accept rule, and must not be used as one. The
|
||||
/// `threshold` it counts against is a field of the certificate — the
|
||||
/// certificate names its own quorum — so on its own this clears a 1-of-n
|
||||
/// certificate that declares `threshold: 1`. It is a building block:
|
||||
|
||||
@@ -216,6 +216,30 @@ pub fn two_thirds_count(n: i64) -> i64 {
|
||||
two_thirds_stake_floor(n as u64) as i64 + 1
|
||||
}
|
||||
|
||||
/// The DISTINCT signers a certificate must carry to attest `tier` over a set of
|
||||
/// `n` signers — the whole of a certificate's authority in seats, and a function
|
||||
/// of the set and the rung, never of the certificate.
|
||||
///
|
||||
/// One definition, read in three places: the assembler picks its alpha from it,
|
||||
/// the weighted predicate enforces it, and the derived-threshold clause compares
|
||||
/// the certificate's own declaration against it. A second spelling anywhere is
|
||||
/// how a certificate acquires a quorum of its own choosing.
|
||||
///
|
||||
/// * Nova — [`nova_signer_floor`], which saturates at three: local execution has
|
||||
/// to stay reachable on a small chain, and it is reorgable.
|
||||
/// * Quasar — [`two_thirds_count`], the export supermajority read in seats, the
|
||||
/// same supermajority the stake clause reads in weight.
|
||||
///
|
||||
/// A rung that is not an accept tier has no floor and gets none: 0, which every
|
||||
/// caller reads as a refusal. Go's `chain.SignerFloor`.
|
||||
pub fn signer_floor(tier: Finality, n: i64) -> i64 {
|
||||
match tier {
|
||||
Finality::Nova => nova_signer_floor(n),
|
||||
Finality::Quasar => two_thirds_count(n),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The minimum vote count that CAN reach the two-thirds-by-stake predicate for a
|
||||
/// weight vector: order heaviest first and count until the running stake first
|
||||
/// exceeds the floor. Returns 0 for an empty set, a zero total, or a vector with
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
//! - **Photon**: Light-based validator sampling with luminance tracking
|
||||
//! - **Focus**: Confidence accumulation through β consecutive rounds
|
||||
//! - **Quasar**: Post-quantum finality with hybrid BLS + Pulsar signatures
|
||||
//! - **Vote**: A signed vote on the wire, and the tally that turns arriving
|
||||
//! votes into a certificate the whole `cert` predicate has already passed
|
||||
//! - **Zap**: The transport frame those votes travel under — the same wire Go
|
||||
//! and C++ speak
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
@@ -68,6 +72,16 @@ pub mod finality;
|
||||
pub mod cert;
|
||||
pub mod pop;
|
||||
|
||||
// The vote plane: what one validator puts on the wire, and the tally that turns
|
||||
// what arrives into a certificate. It states no accept rule of its own — the
|
||||
// rule is `cert`'s, and `vote::Tally::cert` is the only door out of a tally.
|
||||
pub mod vote;
|
||||
|
||||
// The ZAP transport frame the vote plane travels under — the same wire Go's
|
||||
// `luxfi/api/zap` writes and `luxcpp/zap` reads. Layer A only: nothing in it
|
||||
// knows what a vote is.
|
||||
pub mod zap;
|
||||
|
||||
/// SHA-256. The crate's one hash, from the BLS library already linked here.
|
||||
///
|
||||
/// Every derivation the network agrees on runs through this — there is no
|
||||
@@ -95,6 +109,7 @@ pub use crate::focus::*;
|
||||
pub use crate::wave::*;
|
||||
pub use crate::quasar::*;
|
||||
pub use crate::engine::*;
|
||||
pub use crate::vote::{SignedVote, Slot, Tally, VoteTransport, VOTE};
|
||||
|
||||
// ============= TYPES MODULE =============
|
||||
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//! A vote on the wire, and the rule that turns votes into a certificate.
|
||||
//!
|
||||
//! Four things live here and nothing else:
|
||||
//!
|
||||
//! * [`SignedVote`] — one validator's signed statement about one position,
|
||||
//! and its ZAP frame payload.
|
||||
//! * [`Slot`] — the one place a validator may make one statement, read out of
|
||||
//! the signed bytes so equivocation is visible before a key is used.
|
||||
//! * [`VoteTransport`] — the seam a node disseminates over. Consensus states
|
||||
//! the shape and never opens a socket; the node supplies the mesh.
|
||||
//! * [`Tally`] — verify, deduplicate, and issue a [`QuorumCert`] that has
|
||||
//! passed the whole of [`QuorumCert::verify_weighted`].
|
||||
//!
|
||||
//! THE PAYLOAD CARRIES THE SIGNED BYTES. A vote frame holds the exact
|
||||
//! [`canonical_vote_message`] its signer signed, not the fields to rebuild it
|
||||
//! from. A receiver that re-derives the preimage is a second implementation of
|
||||
//! the derivation, and two derivations are how one node verifies a message the
|
||||
//! other never made. The position is recovered *from* the message, so the
|
||||
//! signed bytes and the read position cannot disagree. The C++ lane sends the
|
||||
//! block id alone and re-derives, which is the shape this one refuses; [`VOTE`]
|
||||
//! spells that divergence out and says which side owes the reconciliation.
|
||||
//!
|
||||
//! The transport identity — `block_id`, `parent_id` — is not signed and so is
|
||||
//! not carried; a decoded position leaves those empty, and re-encoding is
|
||||
//! byte-identical because [`Position::signed_identity`] resolves the degrade in
|
||||
//! one place.
|
||||
//!
|
||||
//! THIS MODULE STATES NO ACCEPT RULE. A tally counts what it can verify and
|
||||
//! nothing more; whether the votes it holds amount to finality is
|
||||
//! [`crate::cert`]'s question, asked through [`QuorumCert::verify_weighted`] and
|
||||
//! answered against the LIVE validator set — the signer floor, the stake floor
|
||||
//! and the minimum Byzantine committee, recomputed every time. There is no door
|
||||
//! out of here that skips it: [`Tally::cert`] is the only way to obtain a
|
||||
//! certificate from a tally, and it runs the full predicate before it returns
|
||||
//! one. And a caller states no threshold to begin with: [`Tally::cert`] reads
|
||||
//! the rung's floor off the set at the moment it issues, so there is no
|
||||
//! declaration for the set to disagree with.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::cert::{CertError, NodeId, QuorumCert, StakeSource, Vote, VoteVerifier, SIGNATURE_LEN};
|
||||
use crate::finality::{
|
||||
canonical_vote_message, signer_floor, Finality, Id, Position, EMPTY, QC_FINALITY,
|
||||
QUORUM_CERT_VERSION, VOTE_MESSAGE_LEN, VOTE_TAG,
|
||||
};
|
||||
use crate::pop::NODE_LEN;
|
||||
use crate::zap;
|
||||
|
||||
/// The ZAP message type a consensus vote travels under.
|
||||
///
|
||||
/// It sits in the block Go reserves for Quasar — the finality tier a vote
|
||||
/// carries — and is the next free id there. `luxfi/api/zap` (`wire.go`) spells
|
||||
/// 60 `MsgSetQuasarFinalized` and 61 `MsgQuasarHeight` of the 60..63 range and
|
||||
/// stops, so 62 is unclaimed in the registry the plugin boundary reads.
|
||||
///
|
||||
/// It is deliberately NOT `0x11`. The C++ lane's `vote_codec.hpp` uses `0x11`,
|
||||
/// which is 17, which the Go registry already holds as `MsgResponse`. One id
|
||||
/// space, one meaning: a link that ever carried both would dispatch a vote to
|
||||
/// the response path.
|
||||
///
|
||||
/// THE TWO LANES DO NOT YET CARRY EACH OTHER'S VOTES, and the id is the smaller
|
||||
/// half of why. C++ frames `block_id(32) ‖ pubkey(48) ‖ sig(96)` — 188 payload
|
||||
/// bytes naming its signer by its 48-byte key — where this lane frames
|
||||
/// `signed_message(226) ‖ node(20) ‖ sig(96)`, 354 bytes naming its signer by
|
||||
/// the 20-byte NodeID a proof of possession binds. C++ signs the same
|
||||
/// [`canonical_vote_message`] and then sends only the block id, so its receiver
|
||||
/// rebuilds the preimage it checks — the second derivation this module refuses
|
||||
/// to have.
|
||||
///
|
||||
/// Two of the three have a standard to be held to, and this side is the one
|
||||
/// holding it: 0x11 is `MsgResponse` in the Go registry, and Go names a
|
||||
/// validator by the 20-byte `ids.NodeID` its proof of possession signs over.
|
||||
/// The third is this module's own rule. So the reconciliation is owed on the C++
|
||||
/// side — and until it is paid the two exchange frames neither can read, since
|
||||
/// each refuses the other's type id and, past that, the other's field widths.
|
||||
/// Nothing here should be read as saying they interoperate today.
|
||||
///
|
||||
/// 62 IS NOT UNCLAIMED EVERYWHERE. `github.com/luxfi/mpc`
|
||||
/// (`pkg/transport/wire.go`) extends the same frame with its own 60..69 —
|
||||
/// `MsgMPCReady` is also 62 — so the id is free in the registry and taken on the
|
||||
/// MPC ring. Nothing here is wrong on the validator mesh, which carries votes
|
||||
/// and no MPC traffic and is where these frames travel; and the MPC ring cannot
|
||||
/// be reached by accident either, since it listens on its own port behind
|
||||
/// mandatory PQ TLS 1.3 and closes any connection whose FIRST frame is not a
|
||||
/// `MsgMPCReady` carrying JSON — which a vote payload is not. But "62 is free"
|
||||
/// is true of `api/zap` and not of every link, and a reader deciding where else
|
||||
/// to send a vote should know which. That extension also spells 64..69, above
|
||||
/// the `TYPE_MASK` ceiling, so its 64 is a bare [`zap::ERROR_FLAG`] — a
|
||||
/// registry-wide reconciliation is owed, and this end is not the place to spend
|
||||
/// it.
|
||||
pub const VOTE: u8 = 62;
|
||||
|
||||
/// The exact size of a vote frame payload: three length-prefixed fields.
|
||||
pub const VOTE_PAYLOAD_LEN: usize = 4 + VOTE_MESSAGE_LEN + 4 + NODE_LEN + 4 + SIGNATURE_LEN;
|
||||
|
||||
/// Why a vote was not counted. Every variant means the vote does not happen;
|
||||
/// none of them means "probably fine".
|
||||
///
|
||||
/// The clauses a CERTIFICATE owns are not respelled here — they are carried in
|
||||
/// [`VoteError::Cert`] as the very values [`crate::cert`] names, so the version,
|
||||
/// the role, the tier and every floor have one vocabulary across both modules
|
||||
/// and a caller matching on them matches on one set of facts.
|
||||
///
|
||||
/// A vote plane that grows a refusal must be able to say so without breaking
|
||||
/// every caller that matches on this, so the enum is closed to exhaustive
|
||||
/// matching from outside: a downstream `match` carries a wildcard, and a new
|
||||
/// clause reaches it rather than a compile error. Nothing here is added lightly
|
||||
/// — but it is added before publication, because it can never be added after.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum VoteError {
|
||||
/// The bytes are not a vote: a field of the wrong width, a length that
|
||||
/// outruns the buffer, trailing bytes past the last field, a foreign tag,
|
||||
/// or an accept byte that is neither 0 nor 1.
|
||||
Wire,
|
||||
/// A vote over some other position, offered to this tally. Decided on the
|
||||
/// SIGNED BYTES, so any position that produces this tally's message is this
|
||||
/// tally's statement and no other is.
|
||||
Position,
|
||||
/// A reject. A finality certificate carries accepts, so a reject is not a
|
||||
/// vote a tally can hold — it is a statement about the same slot that a
|
||||
/// caller must handle somewhere else.
|
||||
NotAccept,
|
||||
/// The signer does not resolve in the set, or the signature does not verify
|
||||
/// under the key that does. An unregistered signer is refused here and not
|
||||
/// skipped: a key the set never admitted through
|
||||
/// [`crate::cert::ValidatorSet::insert`] — and so never proved possession of
|
||||
/// — can put nothing behind a vote.
|
||||
Signature,
|
||||
/// A certificate clause refused it, including the two quorum floors and the
|
||||
/// minimum committee.
|
||||
Cert(CertError),
|
||||
}
|
||||
|
||||
impl From<CertError> for VoteError {
|
||||
fn from(e: CertError) -> Self {
|
||||
VoteError::Cert(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for VoteError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
VoteError::Wire => f.write_str("not a vote frame"),
|
||||
VoteError::Position => f.write_str("vote is over another position"),
|
||||
VoteError::NotAccept => f.write_str("a reject is not a finality vote"),
|
||||
VoteError::Signature => f.write_str("signer does not resolve, or signature is bad"),
|
||||
VoteError::Cert(e) => write!(f, "{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for VoteError {}
|
||||
|
||||
/// One validator's signed statement about one position.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SignedVote {
|
||||
pub position: Position,
|
||||
pub accept: bool,
|
||||
pub node: NodeId,
|
||||
pub signature: Vec<u8>,
|
||||
}
|
||||
|
||||
impl SignedVote {
|
||||
/// The bytes this vote's signature is over.
|
||||
pub fn message(&self) -> Vec<u8> {
|
||||
canonical_vote_message(&self.position, self.accept)
|
||||
}
|
||||
|
||||
/// Does the signature verify, under the key the set resolves for this node
|
||||
/// at this epoch? An unresolvable signer is a false, not a skip.
|
||||
pub fn verify(&self, verifier: &dyn VoteVerifier, epoch_height: u64) -> bool {
|
||||
verifier.verify_vote(&self.node, &self.message(), &self.signature, epoch_height)
|
||||
}
|
||||
|
||||
/// The certificate record this vote becomes.
|
||||
pub fn record(&self) -> Vote {
|
||||
Vote {
|
||||
node_id: self.node,
|
||||
accept: self.accept,
|
||||
signature: self.signature.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The ZAP frame payload: the signed message, the signer, the signature.
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut w = zap::Writer::with_capacity(VOTE_PAYLOAD_LEN);
|
||||
w.bytes(&self.message())
|
||||
.bytes(&self.node)
|
||||
.bytes(&self.signature);
|
||||
w.take()
|
||||
}
|
||||
|
||||
/// Read a vote out of a frame payload.
|
||||
///
|
||||
/// Every width is exact and trailing bytes are refused, so `vote ‖ garbage`
|
||||
/// is not a vote and a peer cannot smuggle bytes past the decoder.
|
||||
pub fn decode(payload: &[u8]) -> Result<Self, VoteError> {
|
||||
let mut r = zap::Reader::new(payload);
|
||||
let message = r.bytes().ok_or(VoteError::Wire)?;
|
||||
let node = r.bytes().ok_or(VoteError::Wire)?;
|
||||
let signature = r.bytes().ok_or(VoteError::Wire)?;
|
||||
if r.remaining() != 0 {
|
||||
return Err(VoteError::Wire);
|
||||
}
|
||||
if node.len() != NODE_LEN || signature.len() != SIGNATURE_LEN {
|
||||
return Err(VoteError::Wire);
|
||||
}
|
||||
let (position, accept) = read_message(message)?;
|
||||
Ok(SignedVote {
|
||||
position,
|
||||
accept,
|
||||
node: node.try_into().map_err(|_| VoteError::Wire)?,
|
||||
signature: signature.to_vec(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover the position and the accept bit from the bytes that were signed.
|
||||
///
|
||||
/// The inverse of [`canonical_vote_message`], and checked against it by
|
||||
/// round-trip: the tag, version and role are the network's, or these are not
|
||||
/// this network's signed bytes.
|
||||
pub fn read_message(message: &[u8]) -> Result<(Position, bool), VoteError> {
|
||||
if message.len() != VOTE_MESSAGE_LEN {
|
||||
return Err(VoteError::Wire);
|
||||
}
|
||||
// Offsets are the layout `canonical_vote_message` writes, in order.
|
||||
let tag = VOTE_TAG.len(); // 18
|
||||
if &message[..tag] != VOTE_TAG {
|
||||
return Err(VoteError::Wire);
|
||||
}
|
||||
let version = u16::from_be_bytes([message[tag], message[tag + 1]]);
|
||||
if version != QUORUM_CERT_VERSION {
|
||||
return Err(CertError::Version {
|
||||
got: version,
|
||||
want: QUORUM_CERT_VERSION,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
let role = message[tag + 2];
|
||||
if role != QC_FINALITY {
|
||||
return Err(CertError::Type {
|
||||
got: role,
|
||||
want: QC_FINALITY,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
let id = |at: usize| -> Id { message[at..at + 32].try_into().expect("32 bytes") };
|
||||
let chain_id = id(21);
|
||||
let height = u64::from_be_bytes(message[53..61].try_into().expect("8 bytes"));
|
||||
let round = u32::from_be_bytes(message[61..65].try_into().expect("4 bytes"));
|
||||
let canonical_id = id(65);
|
||||
let parent_canonical_id = id(97);
|
||||
let execution_state_root = id(129);
|
||||
let payload_root = id(161);
|
||||
let validator_set_root = id(193);
|
||||
let accept = match message[225] {
|
||||
0x00 => false,
|
||||
0x01 => true,
|
||||
// The accept byte is bound into the signature, so a third value is a
|
||||
// message no signer produced. Refuse it rather than coerce it to true.
|
||||
_ => return Err(VoteError::Wire),
|
||||
};
|
||||
Ok((
|
||||
Position {
|
||||
chain_id,
|
||||
height,
|
||||
round,
|
||||
block_id: EMPTY,
|
||||
parent_id: EMPTY,
|
||||
canonical_id,
|
||||
parent_canonical_id,
|
||||
execution_state_root,
|
||||
payload_root,
|
||||
validator_set_root,
|
||||
},
|
||||
accept,
|
||||
))
|
||||
}
|
||||
|
||||
/// The one place a validator may make one statement.
|
||||
///
|
||||
/// A chain, a height, and a round. Two messages that share a slot and differ in
|
||||
/// any other byte are the same validator saying two things about one point in
|
||||
/// the chain — an equivocation, which is what a slashing rule is for and what a
|
||||
/// fork is made of. Naming the slot is what lets a signer refuse to be the
|
||||
/// second one.
|
||||
///
|
||||
/// It is derived from the SIGNED bytes, never from the envelope: the transport
|
||||
/// identity is not signed, so two wrappings of one inner block share a slot, as
|
||||
/// they must.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Slot {
|
||||
pub chain: Id,
|
||||
pub height: u64,
|
||||
pub round: u32,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
/// The slot a position falls in.
|
||||
pub fn of(position: &Position) -> Slot {
|
||||
Slot {
|
||||
chain: position.chain_id,
|
||||
height: position.height,
|
||||
round: position.round,
|
||||
}
|
||||
}
|
||||
|
||||
/// The slot a signed message falls in, read out of the message itself.
|
||||
///
|
||||
/// Refuses anything that is not this network's signed bytes, so a slot can
|
||||
/// never be taken from a message the tag, version or role disowns.
|
||||
pub fn read(message: &[u8]) -> Result<Slot, VoteError> {
|
||||
read_message(message).map(|(position, _)| Slot::of(&position))
|
||||
}
|
||||
}
|
||||
|
||||
/// How a node disseminates its own vote.
|
||||
///
|
||||
/// Consensus states the seam and never opens a socket: a node carries this over
|
||||
/// a validator mesh, and a test carries it over a channel. An implementation may
|
||||
/// echo the vote back to its origin — [`Tally`] deduplicates by signer, so a
|
||||
/// self-echo costs nothing and is how an originator's own vote reaches its own
|
||||
/// tally.
|
||||
pub trait VoteTransport {
|
||||
fn broadcast(&self, vote: &SignedVote);
|
||||
}
|
||||
|
||||
/// Votes for one position at one epoch, and the certificate they add up to.
|
||||
///
|
||||
/// A vote is HELD when, and only when, it is an accept, it is over THIS tally's
|
||||
/// position, and its signature verifies under the key the set resolves for its
|
||||
/// signer. Everything else is refused with a reason.
|
||||
///
|
||||
/// Holding is not accepting. Whether the held votes are a quorum is decided by
|
||||
/// [`QuorumCert::verify_weighted`] against the live set, inside [`Tally::cert`],
|
||||
/// which is the only way out — see the module note.
|
||||
///
|
||||
/// A TALLY STATES NO THRESHOLD. There is no field for one and no argument that
|
||||
/// sets one: [`Tally::cert`] reads [`signer_floor`] over the set as it stands
|
||||
/// when the certificate is issued, and that number is what the certificate
|
||||
/// carries. So a caller cannot ask for a lower bar — and cannot pin a higher one
|
||||
/// either, which matters as much: a floor fixed when the tally opened is a floor
|
||||
/// from a set that may no longer exist, and it would strand a tally holding a
|
||||
/// real quorum of the set that does.
|
||||
///
|
||||
/// ONE TALLY, ONE EPOCH. The epoch height is fixed when the tally opens and both
|
||||
/// the signature check and the stake read use it. A tally that verified under
|
||||
/// one epoch's keys and weighed under another's would be checking a signature
|
||||
/// against one set and counting it in another, which is the disagreement
|
||||
/// [`StakeSource`] exists to prevent.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Tally {
|
||||
position: Position,
|
||||
tier: Finality,
|
||||
epoch_height: u64,
|
||||
message: Vec<u8>,
|
||||
votes: BTreeMap<NodeId, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Tally {
|
||||
/// A tally for one position at one rung, at one epoch.
|
||||
///
|
||||
/// A rung that is not Nova or Quasar is not a rung a certificate can claim,
|
||||
/// and is refused here so no later step has to, with the clause
|
||||
/// [`QuorumCert::verify`] would have refused it with. It is the only thing
|
||||
/// left to refuse: the quorum is the set's, and it is read when the
|
||||
/// certificate is issued rather than promised now.
|
||||
pub fn new(position: Position, tier: Finality, epoch_height: u64) -> Result<Self, VoteError> {
|
||||
if tier != Finality::Nova && tier != Finality::Quasar {
|
||||
return Err(CertError::UnknownTier(tier).into());
|
||||
}
|
||||
let message = canonical_vote_message(&position, true);
|
||||
Ok(Tally {
|
||||
position,
|
||||
tier,
|
||||
epoch_height,
|
||||
message,
|
||||
votes: BTreeMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The bytes a validator must sign to be counted here.
|
||||
pub fn message(&self) -> &[u8] {
|
||||
&self.message
|
||||
}
|
||||
|
||||
pub fn position(&self) -> &Position {
|
||||
&self.position
|
||||
}
|
||||
|
||||
pub fn tier(&self) -> Finality {
|
||||
self.tier
|
||||
}
|
||||
|
||||
/// The epoch every signature here was checked under, and the epoch its
|
||||
/// certificate is weighed at.
|
||||
pub fn epoch_height(&self) -> u64 {
|
||||
self.epoch_height
|
||||
}
|
||||
|
||||
/// Distinct signers held so far.
|
||||
pub fn len(&self) -> usize {
|
||||
self.votes.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.votes.is_empty()
|
||||
}
|
||||
|
||||
/// Hold one vote. `Ok(true)` when it was newly recorded, `Ok(false)` when
|
||||
/// that signer was already held — the first signature from a signer stands,
|
||||
/// so an equivocating peer cannot displace what it already said.
|
||||
///
|
||||
/// The already-held test runs BEFORE the signature check, because the check
|
||||
/// is a BLS pairing and the test is a map lookup. A vote arriving off a mesh
|
||||
/// is replayed and echoed as a matter of course, and a peer that could make
|
||||
/// every repeat cost a pairing would have found a way to spend this node's
|
||||
/// CPU by sending the same frame twice.
|
||||
pub fn add(
|
||||
&mut self,
|
||||
vote: &SignedVote,
|
||||
verifier: &dyn VoteVerifier,
|
||||
) -> Result<bool, VoteError> {
|
||||
if !vote.accept {
|
||||
return Err(VoteError::NotAccept);
|
||||
}
|
||||
// Comparing the SIGNED BYTES, not the struct: what the signer committed
|
||||
// to is exactly this message, and any position that produces it is the
|
||||
// same statement.
|
||||
if vote.message() != self.message {
|
||||
return Err(VoteError::Position);
|
||||
}
|
||||
// Held already, so nothing this frame carries can change the tally: the
|
||||
// first signature stands whatever the second one is. Deciding that here
|
||||
// is what keeps a replay off the pairing.
|
||||
if self.votes.contains_key(&vote.node) {
|
||||
return Ok(false);
|
||||
}
|
||||
if !verifier.verify_vote(
|
||||
&vote.node,
|
||||
&self.message,
|
||||
&vote.signature,
|
||||
self.epoch_height,
|
||||
) {
|
||||
return Err(VoteError::Signature);
|
||||
}
|
||||
self.votes.insert(vote.node, vote.signature.clone());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The certificate these votes make — or the clause that refuses it.
|
||||
///
|
||||
/// The certificate's threshold is DERIVED here and now — [`signer_floor`]
|
||||
/// for this rung over the set this source reports at this epoch — and it is
|
||||
/// read at the moment of issue, not when the tally opened. That is the whole
|
||||
/// of a certificate's authority in seats, and it is the set's number: there
|
||||
/// is no argument by which a caller states it, so there is nothing for the
|
||||
/// set to disagree with. Reading it late is what keeps a tally live across a
|
||||
/// set that moves under it; a number pinned at open would be a claim about a
|
||||
/// set that has since changed size, and every party checking the certificate
|
||||
/// re-derives it from the set they see.
|
||||
///
|
||||
/// The whole predicate then runs: [`QuorumCert::verify_weighted`] over the
|
||||
/// live set at this tally's epoch, which is [`QuorumCert::verify`] plus the
|
||||
/// rung's floors — for Quasar the stake supermajority, the distinct-signer
|
||||
/// count and [`crate::finality::MIN_BFT_COMMITTEE`]; for Nova the stake
|
||||
/// majority and `nova_signer_floor`. Every one of them is recomputed from
|
||||
/// the set, so a tally can no more declare its own quorum than a certificate
|
||||
/// can.
|
||||
///
|
||||
/// Signatures are checked twice — once as each vote is held, once as the
|
||||
/// certificate is verified — and that is the point. The certificate a caller
|
||||
/// receives has passed the same check any other party will run on it, so a
|
||||
/// tally cannot issue one that only it believes.
|
||||
pub fn cert(
|
||||
&self,
|
||||
verifier: &dyn VoteVerifier,
|
||||
stake: &dyn StakeSource,
|
||||
) -> Result<QuorumCert, VoteError> {
|
||||
let votes: Vec<Vote> = self
|
||||
.votes
|
||||
.iter()
|
||||
.map(|(node, signature)| Vote {
|
||||
node_id: *node,
|
||||
accept: true,
|
||||
signature: signature.clone(),
|
||||
})
|
||||
.collect();
|
||||
let n = stake.signer_count(self.epoch_height);
|
||||
let derived = signer_floor(self.tier, n);
|
||||
// The floor is a count of seats and the certificate states it in a `u32`.
|
||||
// A set claiming more signers than that can hold is not a set this tally
|
||||
// holds a quorum of, and saying so in the clause that names seats is
|
||||
// truer than truncating the number the certificate would carry.
|
||||
let threshold = u32::try_from(derived).map_err(|_| CertError::SignerFloor {
|
||||
have: self.votes.len() as i64,
|
||||
need: derived,
|
||||
n,
|
||||
})?;
|
||||
let cert = QuorumCert::assemble(self.tier, self.position.clone(), threshold, &votes)?;
|
||||
cert.verify_weighted(verifier, stake, self.epoch_height)?;
|
||||
Ok(cert)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//! The ZAP transport frame, in Rust.
|
||||
//!
|
||||
//! This is layer A only — how a message is put on a stream and taken off it.
|
||||
//! Nothing here knows what a vote is, and nothing here knows what a chain is.
|
||||
//!
|
||||
//! It is the same wire the other two lanes already speak. Go writes it in
|
||||
//! `luxfi/api/zap` (`transport.go`: `WriteMessage`/`ReadMessage`, `Buffer`,
|
||||
//! `Reader`); C++ reads and writes it in `luxcpp/zap`
|
||||
//! (`include/lux/zap/wire.hpp`), which names the Go file as its own reference.
|
||||
//! Rust had no such reader, so the Rust lane could not carry a consensus vote
|
||||
//! between validators at all. One frame:
|
||||
//!
|
||||
//! ```text
|
||||
//! [4-byte BE length][1-byte type][`length` bytes of payload]
|
||||
//! ```
|
||||
//!
|
||||
//! Big-endian everywhere, because that is what Go writes. A field written with
|
||||
//! [`Writer::bytes`] is a 4-byte BE length followed by the raw bytes, matching
|
||||
//! Go's `Buffer.WriteBytes` and C++'s `Writer::write_bytes` byte for byte.
|
||||
//!
|
||||
//! WHY NOT `hanzo-zap`: that crate speaks a different protocol that shares the
|
||||
//! name — the arena message format of `luxfi/zap` (magic `ZAP\0`, a 16-byte
|
||||
//! header, relative-offset slots) framed by a **little-endian** length and no
|
||||
//! type byte. It is not this wire, and a vote written with it is not a frame
|
||||
//! any Go or C++ peer can read.
|
||||
|
||||
use std::io::{self, Read, Write};
|
||||
|
||||
/// Set on every response frame (bit 7).
|
||||
pub const RESPONSE_FLAG: u8 = 0x80;
|
||||
|
||||
/// Set on a response frame that carries an application error (bit 6).
|
||||
pub const ERROR_FLAG: u8 = 0x40;
|
||||
|
||||
/// The bits a service-defined type id may use.
|
||||
///
|
||||
/// A type id with bit 6 or 7 set arrives at the far end looking like a flag, so
|
||||
/// the id space is the low six bits and nothing above them.
|
||||
pub const TYPE_MASK: u8 = 0x3F;
|
||||
|
||||
/// The largest payload a frame may announce: 16 MiB, as Go and C++ both cap it.
|
||||
pub const MAX_MESSAGE: u32 = 16 * 1024 * 1024;
|
||||
|
||||
/// Length prefix plus type byte.
|
||||
pub const HEADER_LEN: usize = 5;
|
||||
|
||||
/// The base type id, with the response and error flags removed.
|
||||
pub const fn strip_flags(raw: u8) -> u8 {
|
||||
raw & TYPE_MASK
|
||||
}
|
||||
|
||||
/// Whether the response flag is set.
|
||||
pub const fn is_response(raw: u8) -> bool {
|
||||
raw & RESPONSE_FLAG != 0
|
||||
}
|
||||
|
||||
/// Whether the error flag is set.
|
||||
pub const fn is_error(raw: u8) -> bool {
|
||||
raw & ERROR_FLAG != 0
|
||||
}
|
||||
|
||||
/// Big-endian encoder for a frame payload.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Writer {
|
||||
buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
pub fn new() -> Self {
|
||||
Writer { buf: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn with_capacity(n: usize) -> Self {
|
||||
Writer {
|
||||
buf: Vec::with_capacity(n),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn u8(&mut self, v: u8) -> &mut Self {
|
||||
self.buf.push(v);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn u32(&mut self, v: u32) -> &mut Self {
|
||||
self.buf.extend_from_slice(&v.to_be_bytes());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn u64(&mut self, v: u64) -> &mut Self {
|
||||
self.buf.extend_from_slice(&v.to_be_bytes());
|
||||
self
|
||||
}
|
||||
|
||||
/// A length-prefixed field: 4-byte BE length, then the bytes.
|
||||
pub fn bytes(&mut self, b: &[u8]) -> &mut Self {
|
||||
self.u32(b.len() as u32);
|
||||
self.buf.extend_from_slice(b);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn take(self) -> Vec<u8> {
|
||||
self.buf
|
||||
}
|
||||
|
||||
pub fn as_slice(&self) -> &[u8] {
|
||||
&self.buf
|
||||
}
|
||||
}
|
||||
|
||||
/// Big-endian decoder over a borrowed payload.
|
||||
///
|
||||
/// Every read is checked and returns `None` on a short buffer; there is no
|
||||
/// panicking accessor, so a hostile payload cannot take the process down.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Reader<'a> {
|
||||
data: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
pub fn new(data: &'a [u8]) -> Self {
|
||||
Reader { data, pos: 0 }
|
||||
}
|
||||
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.data.len() - self.pos
|
||||
}
|
||||
|
||||
pub fn u8(&mut self) -> Option<u8> {
|
||||
let v = *self.data.get(self.pos)?;
|
||||
self.pos += 1;
|
||||
Some(v)
|
||||
}
|
||||
|
||||
pub fn u32(&mut self) -> Option<u32> {
|
||||
let s: [u8; 4] = self.data.get(self.pos..self.pos + 4)?.try_into().ok()?;
|
||||
self.pos += 4;
|
||||
Some(u32::from_be_bytes(s))
|
||||
}
|
||||
|
||||
pub fn u64(&mut self) -> Option<u64> {
|
||||
let s: [u8; 8] = self.data.get(self.pos..self.pos + 8)?.try_into().ok()?;
|
||||
self.pos += 8;
|
||||
Some(u64::from_be_bytes(s))
|
||||
}
|
||||
|
||||
/// A length-prefixed field, borrowed out of the payload.
|
||||
///
|
||||
/// The length is checked against what is left before anything is taken, so
|
||||
/// an announced length larger than the payload is a refusal and never an
|
||||
/// allocation.
|
||||
///
|
||||
/// The end of the field is computed with `checked_add` because the length is
|
||||
/// a peer's 32-bit number and the position is ours. Where `usize` is 32 bits
|
||||
/// the sum of the two can overflow, and an overflow is a PANIC wherever
|
||||
/// overflow checks are on — so a peer that can announce a length can halt
|
||||
/// the process, which is the attack.
|
||||
///
|
||||
/// It is NOT an over-read. A wrapped end lands BELOW the cursor, and a range
|
||||
/// whose start is past its end is one `get` answers `None` to, so the
|
||||
/// unchecked version refuses the same bytes wherever it survives them at
|
||||
/// all. The fix buys the refusal on a target where the alternative is
|
||||
/// dying — the width of a pointer is not a thing a wire format gets to
|
||||
/// depend on.
|
||||
pub fn bytes(&mut self) -> Option<&'a [u8]> {
|
||||
let n = self.u32()? as usize;
|
||||
let end = self.pos.checked_add(n)?;
|
||||
let s = self.data.get(self.pos..end)?;
|
||||
self.pos = end;
|
||||
Some(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write one frame: header then payload, in a single call to the stream.
|
||||
///
|
||||
/// One write, not two, because two writes on a shared stream can interleave with
|
||||
/// another frame's header and desynchronise the reader for good.
|
||||
pub fn write_frame<W: Write>(w: &mut W, msg_type: u8, payload: &[u8]) -> io::Result<()> {
|
||||
if payload.len() as u64 > MAX_MESSAGE as u64 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"zap: frame too large",
|
||||
));
|
||||
}
|
||||
let mut frame = Vec::with_capacity(HEADER_LEN + payload.len());
|
||||
frame.extend_from_slice(&(payload.len() as u32).to_be_bytes());
|
||||
frame.push(msg_type);
|
||||
frame.extend_from_slice(payload);
|
||||
w.write_all(&frame)
|
||||
}
|
||||
|
||||
/// Read one frame, refusing an announced length above `max`.
|
||||
///
|
||||
/// `max` is the caller's statement of what this link carries. A link that only
|
||||
/// ever carries a vote says so, and a peer that announces 16 MiB on it is
|
||||
/// refused before a byte is allocated.
|
||||
pub fn read_frame<R: Read>(r: &mut R, max: u32) -> io::Result<(u8, Vec<u8>)> {
|
||||
let mut header = [0u8; HEADER_LEN];
|
||||
r.read_exact(&mut header)?;
|
||||
let len = u32::from_be_bytes([header[0], header[1], header[2], header[3]]);
|
||||
if len > max.min(MAX_MESSAGE) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"zap: frame too large",
|
||||
));
|
||||
}
|
||||
let mut payload = vec![0u8; len as usize];
|
||||
r.read_exact(&mut payload)?;
|
||||
Ok((header[4], payload))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_frame_is_a_be_length_then_a_type_then_the_payload() {
|
||||
let mut out = Vec::new();
|
||||
write_frame(&mut out, 0x2a, &[1, 2, 3]).expect("write");
|
||||
assert_eq!(out, vec![0, 0, 0, 3, 0x2a, 1, 2, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_written_frame_reads_back() {
|
||||
let mut out = Vec::new();
|
||||
write_frame(&mut out, 62, b"hello").expect("write");
|
||||
let (t, p) = read_frame(&mut out.as_slice(), 1024).expect("read");
|
||||
assert_eq!(t, 62);
|
||||
assert_eq!(p, b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_is_a_be_length_then_the_bytes() {
|
||||
let mut w = Writer::new();
|
||||
w.bytes(&[0xaa, 0xbb]);
|
||||
assert_eq!(w.as_slice(), &[0, 0, 0, 2, 0xaa, 0xbb]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_field_round_trips() {
|
||||
let mut w = Writer::new();
|
||||
w.bytes(b"one").bytes(b"").bytes(b"three");
|
||||
let buf = w.take();
|
||||
let mut r = Reader::new(&buf);
|
||||
assert_eq!(r.bytes(), Some(&b"one"[..]));
|
||||
assert_eq!(r.bytes(), Some(&b""[..]));
|
||||
assert_eq!(r.bytes(), Some(&b"three"[..]));
|
||||
assert_eq!(r.remaining(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_over_long_length_is_refused_not_allocated() {
|
||||
// A four-gigabyte announcement over a two-byte payload.
|
||||
let hostile = [0xffu8, 0xff, 0xff, 0xff, 0x01, 0x02];
|
||||
let mut r = Reader::new(&hostile);
|
||||
assert_eq!(r.bytes(), None);
|
||||
}
|
||||
|
||||
/// A length near `u32::MAX` over a five-byte payload is refused.
|
||||
///
|
||||
/// On a 64-bit target — which is every target this suite runs on — that is
|
||||
/// the whole of what this can say: the sum cannot overflow there, so what is
|
||||
/// held is the bounds check and not the `checked_add`, and dropping the
|
||||
/// `checked_add` leaves this green. The overflow it guards is a 32-bit
|
||||
/// target's, no such target is in CI, and this test is the one that would
|
||||
/// carry the property the day one is.
|
||||
#[test]
|
||||
fn a_length_that_would_wrap_the_cursor_is_refused() {
|
||||
for announced in [u32::MAX, u32::MAX - 1, 1 << 31, 1 << 24] {
|
||||
let mut hostile = announced.to_be_bytes().to_vec();
|
||||
hostile.extend_from_slice(b"short");
|
||||
let mut r = Reader::new(&hostile);
|
||||
assert_eq!(r.bytes(), None, "announced {announced}");
|
||||
// And a refusal leaves the cursor where a caller can still see the
|
||||
// remaining bytes, rather than past the end of the buffer.
|
||||
assert!(r.remaining() <= hostile.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_over_the_links_limit_is_refused() {
|
||||
let mut framed = Vec::new();
|
||||
write_frame(&mut framed, 62, &vec![0u8; 300]).expect("write");
|
||||
let err = read_frame(&mut framed.as_slice(), 188).expect_err("must refuse");
|
||||
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_frame_is_an_error_not_a_short_payload() {
|
||||
let framed = [0u8, 0, 0, 8, 62, 1, 2, 3];
|
||||
let err = read_frame(&mut framed.as_slice(), 1024).expect_err("must refuse");
|
||||
assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
|
||||
}
|
||||
|
||||
/// The frame's constants are not this crate's to choose. Every one of them
|
||||
/// is read off `luxfi/api/zap` (`wire.go`) and matched by `luxcpp/zap`
|
||||
/// (`include/lux/zap/wire.hpp`); a frame written under any other value is
|
||||
/// one no Go or C++ peer can read. Pinning them here means a change to one
|
||||
/// of them stops being a silent fork of the wire.
|
||||
#[test]
|
||||
fn the_constants_are_the_ones_go_writes_and_cpp_reads() {
|
||||
// Go: MsgResponseFlag = 0x80, MsgErrorFlag = 0x40.
|
||||
// C++: MsgResponseFlag = 0x80, MsgErrorFlag = 0x40.
|
||||
assert_eq!(RESPONSE_FLAG, 0x80);
|
||||
assert_eq!(ERROR_FLAG, 0x40);
|
||||
// C++: MsgTypeMask = 0x3F. Go has no such constant and open-codes the
|
||||
// strip; `strip_flags_is_gos_own_expression` below is what holds them
|
||||
// together.
|
||||
assert_eq!(TYPE_MASK, 0x3F);
|
||||
// Go: MaxMessageSize = 16 * 1024 * 1024. C++: MaxMessageSize, same.
|
||||
assert_eq!(MAX_MESSAGE, 16 * 1024 * 1024);
|
||||
// Go: HeaderSize = 5. C++: HeaderSize = 5.
|
||||
assert_eq!(HEADER_LEN, 5);
|
||||
}
|
||||
|
||||
/// Go names no mask: its call sites clear the two flag bits with `&^`, as in
|
||||
/// `respType &^ (MsgResponseFlag | MsgErrorFlag)`. Masking with `TYPE_MASK`
|
||||
/// is the same operation given a name, and this is the proof — over every
|
||||
/// byte, not over a sample.
|
||||
#[test]
|
||||
fn strip_flags_is_gos_own_expression() {
|
||||
for raw in 0..=u8::MAX {
|
||||
assert_eq!(
|
||||
strip_flags(raw),
|
||||
raw & !(RESPONSE_FLAG | ERROR_FLAG),
|
||||
"raw {raw}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The header Go builds is `BigEndian.PutUint32(header[0:4], len(payload))`
|
||||
/// then `header[4] = byte(msgType)`. Written out, so an endianness that ever
|
||||
/// flipped would be caught by the bytes and not by a peer.
|
||||
#[test]
|
||||
fn the_header_is_big_endian_length_then_type() {
|
||||
let mut out = Vec::new();
|
||||
write_frame(&mut out, VOTE_LIKE, &[0xde, 0xad]).expect("write");
|
||||
assert_eq!(
|
||||
&out[..4],
|
||||
&[0x00, 0x00, 0x00, 0x02],
|
||||
"length is 4-byte big-endian"
|
||||
);
|
||||
assert_eq!(out[4], VOTE_LIKE, "then the type byte");
|
||||
assert_eq!(&out[5..], &[0xde, 0xad]);
|
||||
|
||||
// A length that needs more than one byte, so a little-endian writer
|
||||
// could not pass by accident.
|
||||
let mut out = Vec::new();
|
||||
write_frame(&mut out, VOTE_LIKE, &vec![0u8; 258]).expect("write");
|
||||
assert_eq!(&out[..4], &[0x00, 0x00, 0x01, 0x02]);
|
||||
}
|
||||
|
||||
/// `Buffer.WriteBytes` in Go writes a 4-byte big-endian length and then the
|
||||
/// raw bytes; `Writer::write_bytes` in C++ writes the same. A field is the
|
||||
/// same shape as a frame's length prefix and must not drift from it.
|
||||
#[test]
|
||||
fn a_field_is_big_endian_too() {
|
||||
let mut w = Writer::new();
|
||||
w.bytes(&vec![0u8; 300]);
|
||||
assert_eq!(&w.as_slice()[..4], &[0x00, 0x00, 0x01, 0x2c]);
|
||||
}
|
||||
|
||||
/// A service id fit for this wire: the 60..63 block Go reserves for Quasar.
|
||||
const VOTE_LIKE: u8 = 62;
|
||||
|
||||
#[test]
|
||||
fn flags_ride_above_the_id_space() {
|
||||
assert_eq!(strip_flags(62 | RESPONSE_FLAG | ERROR_FLAG), 62);
|
||||
assert!(is_response(62 | RESPONSE_FLAG));
|
||||
assert!(is_error(62 | ERROR_FLAG));
|
||||
assert!(!is_response(62));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,946 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//! The vote plane: the frame, the slot, and the tally.
|
||||
//!
|
||||
//! Two questions are asked here and they are not the same question.
|
||||
//!
|
||||
//! The first is whether a vote survives the wire unchanged — whether the bytes
|
||||
//! a validator signed are the bytes a receiver checks, and whether every
|
||||
//! near-miss frame is refused rather than repaired. `SignedVote` carries the
|
||||
//! signed message itself, so this is answered by round-trip and by mutation.
|
||||
//!
|
||||
//! The second is whether a tally can hand out a certificate the network would
|
||||
//! not accept. It must not be able to. A tally holds votes; it states no accept
|
||||
//! rule; the rule is `cert`'s, and [`Tally::cert`] is the only door out of a
|
||||
//! tally and runs the whole of `verify_weighted` before it returns. The tests
|
||||
//! from `a_tally_derives_its_quorum_from_the_set` down are that property, one
|
||||
//! floor at a time: the stake supermajority, the distinct-signer count, the
|
||||
//! minimum Byzantine committee, and — through registration — the proof of
|
||||
//! possession that decides who may be counted at all.
|
||||
|
||||
use blst::min_pk::SecretKey;
|
||||
use lux_consensus::cert::{
|
||||
CertError, NodeId, QuorumCert, StakeSource, ValidatorSet, VoteVerifier, DST,
|
||||
};
|
||||
use lux_consensus::finality::{
|
||||
canonical_vote_message, half_stake_floor, nova_signer_floor, signer_floor, two_thirds_count,
|
||||
two_thirds_stake_floor, Finality, Position, EMPTY, VOTE_MESSAGE_LEN,
|
||||
};
|
||||
use lux_consensus::pop::{self, NODE_LEN};
|
||||
use lux_consensus::vote::{
|
||||
read_message, SignedVote, Slot, Tally, VoteError, VoteTransport, VOTE, VOTE_PAYLOAD_LEN,
|
||||
};
|
||||
use lux_consensus::zap;
|
||||
|
||||
/// The epoch these tests register and vote at. One value, so a test that meant
|
||||
/// to change it has to say so.
|
||||
const EPOCH: u64 = 9;
|
||||
|
||||
/// A validator: a key, an id, and a weight.
|
||||
struct Signer {
|
||||
id: NodeId,
|
||||
sk: SecretKey,
|
||||
weight: u64,
|
||||
}
|
||||
|
||||
impl Signer {
|
||||
/// Deterministic per `n`, so a failure reproduces exactly.
|
||||
fn new(n: u8, weight: u64) -> Self {
|
||||
let sk = SecretKey::key_gen(&[n; 32], &[]).expect("key_gen");
|
||||
let mut id = [0u8; NODE_LEN];
|
||||
id[0] = n;
|
||||
Signer { id, sk, weight }
|
||||
}
|
||||
|
||||
fn public(&self) -> [u8; 48] {
|
||||
self.sk.sk_to_pk().compress()
|
||||
}
|
||||
|
||||
/// The proof of possession this validator presents at registration.
|
||||
fn pop(&self) -> Vec<u8> {
|
||||
pop::sign(&self.sk, &self.id, &self.public())
|
||||
}
|
||||
|
||||
/// A signed statement about `pos`. The signature is over the canonical
|
||||
/// message, which is the only thing a verifier ever checks.
|
||||
fn vote(&self, pos: &Position, accept: bool) -> SignedVote {
|
||||
let message = canonical_vote_message(pos, accept);
|
||||
SignedVote {
|
||||
position: pos.clone(),
|
||||
accept,
|
||||
node: self.id,
|
||||
signature: self.sk.sign(&message, DST, &[]).compress().to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A committee of `n` equal-weight validators, admitted through the proof path.
|
||||
fn committee(n: u8, weight: u64) -> (Vec<Signer>, ValidatorSet) {
|
||||
let signers: Vec<Signer> = (1..=n).map(|i| Signer::new(i, weight)).collect();
|
||||
let mut set = ValidatorSet::new();
|
||||
for s in &signers {
|
||||
set.insert(s.id, s.weight, &s.public(), &s.pop())
|
||||
.expect("insert");
|
||||
}
|
||||
(signers, set)
|
||||
}
|
||||
|
||||
/// A position with every slot populated, so the canonical-degrade path is not
|
||||
/// what is under test.
|
||||
fn position() -> Position {
|
||||
Position {
|
||||
chain_id: [0xc1; 32],
|
||||
height: 42,
|
||||
round: 7,
|
||||
block_id: [0xb1; 32],
|
||||
parent_id: [0xb2; 32],
|
||||
canonical_id: [0xca; 32],
|
||||
parent_canonical_id: [0xcb; 32],
|
||||
execution_state_root: [0xe5; 32],
|
||||
payload_root: [0x70; 32],
|
||||
validator_set_root: [0x55; 32],
|
||||
}
|
||||
}
|
||||
|
||||
/// A tally at [`EPOCH`], for the rung under test. There is nothing else to
|
||||
/// state: the quorum is the set's, read when a certificate is asked for.
|
||||
fn tally(tier: Finality) -> Tally {
|
||||
Tally::new(position(), tier, EPOCH).expect("a tally opens")
|
||||
}
|
||||
|
||||
// ── The frame ───────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_vote_frame_round_trips() {
|
||||
let (s, _) = committee(1, 1);
|
||||
let v = s[0].vote(&position(), true);
|
||||
let payload = v.encode();
|
||||
assert_eq!(payload.len(), VOTE_PAYLOAD_LEN);
|
||||
|
||||
let back = SignedVote::decode(&payload).expect("decode");
|
||||
// The transport ids are not signed, so they do not survive — and the signed
|
||||
// bytes are identical either way, which is the property that matters.
|
||||
assert_eq!(back.message(), v.message());
|
||||
assert_eq!(back.node, v.node);
|
||||
assert_eq!(back.signature, v.signature);
|
||||
assert!(back.accept);
|
||||
assert_eq!(back.encode(), payload);
|
||||
assert_eq!(
|
||||
back.position.block_id, EMPTY,
|
||||
"the envelope is not in the signature"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_position_with_no_inner_outer_split_round_trips_too() {
|
||||
// The degrade case: canonical slots unset, so the message binds the
|
||||
// transport ids. Re-encoding must produce the same bytes.
|
||||
let mut pos = position();
|
||||
pos.canonical_id = EMPTY;
|
||||
pos.parent_canonical_id = EMPTY;
|
||||
let (s, _) = committee(1, 1);
|
||||
let v = s[0].vote(&pos, true);
|
||||
let back = SignedVote::decode(&v.encode()).expect("decode");
|
||||
assert_eq!(back.message(), v.message());
|
||||
assert_eq!(back.encode(), v.encode());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_bytes_are_not_a_vote() {
|
||||
let (s, _) = committee(1, 1);
|
||||
let mut payload = s[0].vote(&position(), true).encode();
|
||||
payload.push(0);
|
||||
assert_eq!(SignedVote::decode(&payload), Err(VoteError::Wire));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_width_field_is_not_a_vote() {
|
||||
let (s, _) = committee(1, 1);
|
||||
let v = s[0].vote(&position(), true);
|
||||
|
||||
// A 19-byte node id.
|
||||
let mut w = zap::Writer::new();
|
||||
w.bytes(&v.message())
|
||||
.bytes(&v.node[..NODE_LEN - 1])
|
||||
.bytes(&v.signature);
|
||||
assert_eq!(SignedVote::decode(&w.take()), Err(VoteError::Wire));
|
||||
|
||||
// A 95-byte signature.
|
||||
let mut w = zap::Writer::new();
|
||||
w.bytes(&v.message())
|
||||
.bytes(&v.node)
|
||||
.bytes(&v.signature[..95]);
|
||||
assert_eq!(SignedVote::decode(&w.take()), Err(VoteError::Wire));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_truncated_payload_is_not_a_vote() {
|
||||
let (s, _) = committee(1, 1);
|
||||
let payload = s[0].vote(&position(), true).encode();
|
||||
for cut in [0, 1, 4, 100, VOTE_PAYLOAD_LEN - 1] {
|
||||
assert_eq!(
|
||||
SignedVote::decode(&payload[..cut]),
|
||||
Err(VoteError::Wire),
|
||||
"cut {cut}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_foreign_tag_is_not_this_networks_vote() {
|
||||
let (s, _) = committee(1, 1);
|
||||
let v = s[0].vote(&position(), true);
|
||||
let mut message = v.message();
|
||||
message[0] = b'X';
|
||||
let mut w = zap::Writer::new();
|
||||
w.bytes(&message).bytes(&v.node).bytes(&v.signature);
|
||||
assert_eq!(SignedVote::decode(&w.take()), Err(VoteError::Wire));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_third_accept_value_is_refused() {
|
||||
let (s, _) = committee(1, 1);
|
||||
let v = s[0].vote(&position(), true);
|
||||
let mut message = v.message();
|
||||
*message.last_mut().expect("accept byte") = 0x02;
|
||||
let mut w = zap::Writer::new();
|
||||
w.bytes(&message).bytes(&v.node).bytes(&v.signature);
|
||||
assert_eq!(SignedVote::decode(&w.take()), Err(VoteError::Wire));
|
||||
}
|
||||
|
||||
/// A refusal names the clause. The version and the role live in the signed
|
||||
/// bytes, so a message from another certificate version is refused as THAT, and
|
||||
/// with the same values `cert` would have refused it with — one vocabulary
|
||||
/// across both modules.
|
||||
#[test]
|
||||
fn a_foreign_version_and_a_foreign_role_are_named() {
|
||||
let base = canonical_vote_message(&position(), true);
|
||||
|
||||
let mut wrong_version = base.clone();
|
||||
wrong_version[18] = 0;
|
||||
wrong_version[19] = 99;
|
||||
assert_eq!(
|
||||
read_message(&wrong_version),
|
||||
Err(VoteError::Cert(CertError::Version { got: 99, want: 3 })),
|
||||
);
|
||||
|
||||
let mut wrong_role = base;
|
||||
wrong_role[20] = 7;
|
||||
assert_eq!(
|
||||
read_message(&wrong_role),
|
||||
Err(VoteError::Cert(CertError::Type { got: 7, want: 1 })),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_message_is_the_length_the_standard_fixes() {
|
||||
assert_eq!(
|
||||
canonical_vote_message(&position(), true).len(),
|
||||
VOTE_MESSAGE_LEN
|
||||
);
|
||||
assert_eq!(read_message(&[]), Err(VoteError::Wire));
|
||||
assert_eq!(
|
||||
read_message(&[0u8; VOTE_MESSAGE_LEN - 1]),
|
||||
Err(VoteError::Wire)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_vote_type_id_fits_the_id_space() {
|
||||
// A type id with bit 6 or 7 set arrives at the far end looking like a flag,
|
||||
// so stripping the flags must leave the id untouched.
|
||||
assert_eq!(
|
||||
zap::strip_flags(VOTE),
|
||||
VOTE,
|
||||
"a type id must not collide with a flag"
|
||||
);
|
||||
}
|
||||
|
||||
// ── The slot ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn a_slot_is_the_chain_the_height_and_the_round() {
|
||||
let pos = Position {
|
||||
chain_id: [7u8; 32],
|
||||
height: 42,
|
||||
round: 3,
|
||||
..Default::default()
|
||||
};
|
||||
let slot = Slot::of(&pos);
|
||||
assert_eq!(
|
||||
slot,
|
||||
Slot {
|
||||
chain: [7u8; 32],
|
||||
height: 42,
|
||||
round: 3
|
||||
}
|
||||
);
|
||||
// And it is readable out of the signed bytes, which is where a receiver gets
|
||||
// it from.
|
||||
assert_eq!(
|
||||
Slot::read(&canonical_vote_message(&pos, true)).unwrap(),
|
||||
slot
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepting_and_rejecting_share_one_slot() {
|
||||
// Two statements a validator must not both make: same point in the chain,
|
||||
// opposite answers.
|
||||
let pos = Position {
|
||||
chain_id: [9u8; 32],
|
||||
height: 1,
|
||||
round: 0,
|
||||
..Default::default()
|
||||
};
|
||||
let yes = canonical_vote_message(&pos, true);
|
||||
let no = canonical_vote_message(&pos, false);
|
||||
assert_ne!(yes, no, "the accept bit is signed");
|
||||
assert_eq!(Slot::read(&yes).unwrap(), Slot::read(&no).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_wrappings_of_one_block_share_one_slot() {
|
||||
// The transport identity is not signed, so a block wrapped twice is one
|
||||
// statement, not two.
|
||||
let inner = Position {
|
||||
chain_id: [4u8; 32],
|
||||
height: 8,
|
||||
round: 1,
|
||||
canonical_id: [5u8; 32],
|
||||
block_id: [0xAA; 32],
|
||||
..Default::default()
|
||||
};
|
||||
let other = Position {
|
||||
block_id: [0xBB; 32],
|
||||
..inner.clone()
|
||||
};
|
||||
assert_eq!(Slot::of(&inner), Slot::of(&other));
|
||||
assert_eq!(
|
||||
canonical_vote_message(&inner, true),
|
||||
canonical_vote_message(&other, true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_message_this_network_disowns_has_no_slot() {
|
||||
assert_eq!(Slot::read(&[]), Err(VoteError::Wire));
|
||||
let mut message = canonical_vote_message(&Position::default(), true);
|
||||
message[0] ^= 0xFF; // the tag
|
||||
assert_eq!(Slot::read(&message), Err(VoteError::Wire));
|
||||
}
|
||||
|
||||
// ── What a tally will hold ──────────────────────────────────────────
|
||||
|
||||
/// A tally opens at a rung a certificate can claim, or it does not open. That is
|
||||
/// the whole of what opening one can refuse — there is no threshold to state, so
|
||||
/// a quorum of nobody is not something a caller can ask for in the first place.
|
||||
#[test]
|
||||
fn a_tally_opens_only_at_an_accept_rung() {
|
||||
assert_eq!(
|
||||
Tally::new(position(), Finality::Wave, EPOCH),
|
||||
Err(VoteError::Cert(CertError::UnknownTier(Finality::Wave))),
|
||||
);
|
||||
Tally::new(position(), Finality::Nova, EPOCH).expect("nova opens");
|
||||
Tally::new(position(), Finality::Quasar, EPOCH).expect("quasar opens");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_signer_counts_once() {
|
||||
let (s, set) = committee(2, 1);
|
||||
let mut t = tally(Finality::Quasar);
|
||||
let v = s[0].vote(&position(), true);
|
||||
assert!(t.add(&v, &set).expect("first"));
|
||||
assert!(
|
||||
!t.add(&v, &set).expect("second"),
|
||||
"a repeat is not a second signer"
|
||||
);
|
||||
assert_eq!(t.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reject_is_not_a_finality_vote() {
|
||||
let (s, set) = committee(1, 1);
|
||||
let mut t = tally(Finality::Quasar);
|
||||
assert_eq!(
|
||||
t.add(&s[0].vote(&position(), false), &set),
|
||||
Err(VoteError::NotAccept)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vote_for_another_position_does_not_count_here() {
|
||||
let (s, set) = committee(1, 1);
|
||||
let mut other = position();
|
||||
other.height += 1;
|
||||
let mut t = tally(Finality::Quasar);
|
||||
assert_eq!(
|
||||
t.add(&s[0].vote(&other, true), &set),
|
||||
Err(VoteError::Position)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forged_signature_is_refused() {
|
||||
let (s, set) = committee(2, 1);
|
||||
// Node 0's identity carrying node 1's signature.
|
||||
let mut v = s[1].vote(&position(), true);
|
||||
v.node = s[0].id;
|
||||
let mut t = tally(Finality::Quasar);
|
||||
assert_eq!(t.add(&v, &set), Err(VoteError::Signature));
|
||||
}
|
||||
|
||||
/// A real key over the real message, from a node the set never admitted. There
|
||||
/// is nothing wrong with the signature; there is no one it stands for.
|
||||
#[test]
|
||||
fn a_signer_the_set_never_admitted_is_refused() {
|
||||
let (_, set) = committee(1, 1);
|
||||
let stranger = Signer::new(0xff, 1);
|
||||
let mut t = tally(Finality::Quasar);
|
||||
assert_eq!(
|
||||
t.add(&stranger.vote(&position(), true), &set),
|
||||
Err(VoteError::Signature)
|
||||
);
|
||||
assert!(t.is_empty());
|
||||
}
|
||||
|
||||
/// A member the chain carries without a key. It counts toward membership and
|
||||
/// can never put a vote behind its stake — the fail-closed direction, held
|
||||
/// through the vote plane and not only at the certificate.
|
||||
#[test]
|
||||
fn a_keyless_member_cannot_be_counted() {
|
||||
let (s, mut set) = committee(4, 1);
|
||||
let spectator = Signer::new(0x40, 1_000_000);
|
||||
set.insert_unkeyed(spectator.id, spectator.weight)
|
||||
.expect("a keyless member is a member");
|
||||
|
||||
let mut t = tally(Finality::Quasar);
|
||||
assert_eq!(
|
||||
t.add(&spectator.vote(&position(), true), &set),
|
||||
Err(VoteError::Signature)
|
||||
);
|
||||
assert!(t
|
||||
.add(&s[0].vote(&position(), true), &set)
|
||||
.expect("a keyed member is held"));
|
||||
assert_eq!(t.len(), 1);
|
||||
}
|
||||
|
||||
// ── What a tally will NOT certify ───────────────────────────────────
|
||||
|
||||
/// THE PROPERTY THIS MODULE EXISTS FOR.
|
||||
///
|
||||
/// A tally is handed one perfectly valid signature over a set of seven, and its
|
||||
/// holder has no way to ask for a quorum of one — there is no argument for it.
|
||||
/// The number the certificate would carry is `signer_floor` over the set, five
|
||||
/// of seven, so the lone vote is refused by a floor the caller had no part in.
|
||||
#[test]
|
||||
fn a_tally_derives_its_quorum_from_the_set() {
|
||||
let (s, set) = committee(7, 1);
|
||||
let mut t = tally(Finality::Quasar);
|
||||
assert!(t.add(&s[0].vote(&position(), true), &set).expect("held"));
|
||||
|
||||
assert_eq!(
|
||||
t.cert(&set, &set),
|
||||
Err(VoteError::Cert(CertError::BelowThreshold {
|
||||
have: 1,
|
||||
need: two_thirds_count(7) as u32,
|
||||
})),
|
||||
"one of seven is not the quorum the set derives",
|
||||
);
|
||||
|
||||
// And the number the certificate ends up carrying is that same derived
|
||||
// floor — the one every party checking it computes from the set it holds,
|
||||
// rather than a value it reads off the certificate and believes.
|
||||
for signer in s.iter().skip(1).take(4) {
|
||||
assert!(t.add(&signer.vote(&position(), true), &set).expect("held"));
|
||||
}
|
||||
let cert = t.cert(&set, &set).expect("five of seven exports");
|
||||
assert_eq!(i64::from(cert.threshold), signer_floor(Finality::Quasar, 7));
|
||||
assert_eq!(cert.threshold, 5);
|
||||
}
|
||||
|
||||
/// The rung certifies at the set's floor, and the vote before it is not enough.
|
||||
/// On an equal-weight set the two Quasar floors are one bar in two units, so the
|
||||
/// last vote crosses both at once.
|
||||
///
|
||||
/// There is ONE bar here where there used to be two. A tally carries no
|
||||
/// declaration beside the set's floor, so there is no arrangement of the two in
|
||||
/// which the caller's number is the one that decides: the certificate is built
|
||||
/// to the floor the set derives, or it is not built.
|
||||
#[test]
|
||||
fn a_quasar_tally_certifies_at_the_sets_floor_and_not_before() {
|
||||
let (s, set) = committee(7, 1);
|
||||
let need = two_thirds_count(7);
|
||||
assert_eq!(need, 5);
|
||||
let short = need as usize - 1;
|
||||
|
||||
let mut t = tally(Finality::Quasar);
|
||||
for signer in s.iter().take(short) {
|
||||
assert!(t.add(&signer.vote(&position(), true), &set).expect("held"));
|
||||
}
|
||||
assert_eq!(
|
||||
t.cert(&set, &set),
|
||||
Err(VoteError::Cert(CertError::BelowThreshold {
|
||||
have: 4,
|
||||
need: 5
|
||||
})),
|
||||
"four of seven is not an export quorum, and nothing can ask for less",
|
||||
);
|
||||
|
||||
// The fifth vote crosses both floors at once.
|
||||
assert!(t
|
||||
.add(&s[short].vote(&position(), true), &set)
|
||||
.expect("held"));
|
||||
let cert = t.cert(&set, &set).expect("five of seven exports");
|
||||
assert_eq!(cert.votes.len(), 5);
|
||||
assert_eq!(cert.tier, Finality::Quasar);
|
||||
assert_eq!(cert.threshold, need as u32);
|
||||
}
|
||||
|
||||
/// The two export floors are independent, and on a lopsided set each binds
|
||||
/// alone: the whale holds the stake and not the seats, the small holders hold
|
||||
/// the seats and not the stake. Neither exports. This is why the rule is read in
|
||||
/// both units — two thirds of the stake is one signature wherever two thirds of
|
||||
/// the stake is one validator.
|
||||
#[test]
|
||||
fn neither_stake_nor_seats_alone_exports() {
|
||||
let whale = Signer::new(1, 1_000);
|
||||
let rest: Vec<Signer> = (2..=7).map(|i| Signer::new(i, 1)).collect();
|
||||
let mut set = ValidatorSet::new();
|
||||
for s in std::iter::once(&whale).chain(rest.iter()) {
|
||||
set.insert(s.id, s.weight, &s.public(), &s.pop())
|
||||
.expect("insert");
|
||||
}
|
||||
|
||||
// Stake without seats. The whale's 1000 of 1006 is past floor(2·1006/3) =
|
||||
// 670, so the stake clause would pass; one of seven is not the five seats
|
||||
// the set derives, and the seat floor is what refuses.
|
||||
assert!(1_000 > two_thirds_stake_floor(1_006));
|
||||
let mut alone = tally(Finality::Quasar);
|
||||
assert!(alone
|
||||
.add(&whale.vote(&position(), true), &set)
|
||||
.expect("held"));
|
||||
assert_eq!(
|
||||
alone.cert(&set, &set),
|
||||
Err(VoteError::Cert(CertError::BelowThreshold {
|
||||
have: 1,
|
||||
need: two_thirds_count(7) as u32,
|
||||
})),
|
||||
"the stake was there and the seats were not",
|
||||
);
|
||||
|
||||
// Seats without stake. Five of the seven signers is the seat floor exactly,
|
||||
// so the certificate is built — and the stake clause refuses it on five
|
||||
// units of 1006.
|
||||
let mut small = tally(Finality::Quasar);
|
||||
for s in rest.iter().take(5) {
|
||||
assert!(small.add(&s.vote(&position(), true), &set).expect("held"));
|
||||
}
|
||||
assert_eq!(
|
||||
small.cert(&set, &set),
|
||||
Err(VoteError::Cert(CertError::StakeBelowSupermajority {
|
||||
voted: 5,
|
||||
signer: 1_006,
|
||||
need_above: two_thirds_stake_floor(1_006),
|
||||
})),
|
||||
"the seats were there and the stake was not",
|
||||
);
|
||||
}
|
||||
|
||||
/// A DERIVED FLOOR IS THE LIVE ONE.
|
||||
///
|
||||
/// A floor read when the tally opened is a claim about a set that may no longer
|
||||
/// exist, and a set that SHRINKS strands a tally holding it: the pinned number
|
||||
/// counts validators who have gone. Nothing is pinned, so the same held votes
|
||||
/// are weighed against whichever set is live when the certificate is asked for —
|
||||
/// short of the ten's floor, a supermajority of the seven's.
|
||||
#[test]
|
||||
fn a_set_that_moves_under_a_tally_does_not_strand_it() {
|
||||
let signers: Vec<Signer> = (1..=10).map(|i| Signer::new(i, 1)).collect();
|
||||
let admit = |take: usize| -> ValidatorSet {
|
||||
let mut set = ValidatorSet::new();
|
||||
for s in signers.iter().take(take) {
|
||||
set.insert(s.id, s.weight, &s.public(), &s.pop())
|
||||
.expect("insert");
|
||||
}
|
||||
set
|
||||
};
|
||||
let wide = admit(10);
|
||||
let narrow = admit(7); // three keys gone at the same epoch
|
||||
|
||||
let mut t = tally(Finality::Quasar);
|
||||
for s in signers.iter().take(6) {
|
||||
assert!(t.add(&s.vote(&position(), true), &wide).expect("held"));
|
||||
}
|
||||
|
||||
// Against the ten, six seats are short of the seven that set derives.
|
||||
assert_eq!(two_thirds_count(10), 7);
|
||||
assert_eq!(
|
||||
t.cert(&wide, &wide),
|
||||
Err(VoteError::Cert(CertError::BelowThreshold {
|
||||
have: 6,
|
||||
need: 7
|
||||
})),
|
||||
);
|
||||
|
||||
// Against the seven, the same six votes are a supermajority, and the
|
||||
// certificate carries the floor the seven derive rather than the ten's.
|
||||
let cert = t.cert(&narrow, &narrow).expect("six of seven exports");
|
||||
assert_eq!(i64::from(cert.threshold), signer_floor(Finality::Quasar, 7));
|
||||
assert_eq!(cert.threshold, 5);
|
||||
cert.verify_weighted(&narrow, &narrow, EPOCH)
|
||||
.expect("and it verifies for anyone holding that set");
|
||||
}
|
||||
|
||||
/// A SOURCE THAT CANNOT BE ANSWERED IS REFUSED, not rounded to one that can.
|
||||
///
|
||||
/// The floor is a count of seats and a certificate states it in 32 bits. A
|
||||
/// source claiming more signers than that can hold has claimed a set no
|
||||
/// certificate here can speak for; truncating the number would build one naming
|
||||
/// a quorum nobody derived. It fails closed instead, in the clause that names
|
||||
/// seats — which is also the honest reading, since four voters are not
|
||||
/// six quintillion.
|
||||
#[test]
|
||||
fn a_signer_count_past_the_certificates_field_is_refused() {
|
||||
struct Absurd<'a>(&'a ValidatorSet);
|
||||
impl StakeSource for Absurd<'_> {
|
||||
fn weight(&self, n: &NodeId, h: u64) -> u64 {
|
||||
self.0.weight(n, h)
|
||||
}
|
||||
fn signer_stake(&self, h: u64) -> u64 {
|
||||
self.0.signer_stake(h)
|
||||
}
|
||||
fn signer_count(&self, _h: u64) -> i64 {
|
||||
i64::MAX
|
||||
}
|
||||
}
|
||||
|
||||
let (s, set) = committee(4, 1);
|
||||
let mut t = tally(Finality::Quasar);
|
||||
for signer in &s {
|
||||
assert!(t.add(&signer.vote(&position(), true), &set).expect("held"));
|
||||
}
|
||||
assert_eq!(
|
||||
t.cert(&set, &Absurd(&set)),
|
||||
Err(VoteError::Cert(CertError::SignerFloor {
|
||||
have: 4,
|
||||
need: two_thirds_count(i64::MAX),
|
||||
n: i64::MAX,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/// A REPLAY DOES NO PAIRING.
|
||||
///
|
||||
/// A vote that arrives twice is decided by the map and not by the curve: the
|
||||
/// already-held test runs before the signature check. A mesh replays and echoes
|
||||
/// as a matter of course, so a peer that could make every repeat cost a BLS
|
||||
/// verification would have found this node's CPU by sending one frame twice.
|
||||
#[test]
|
||||
fn a_replayed_vote_is_dropped_before_the_signature_check() {
|
||||
use std::cell::Cell;
|
||||
|
||||
/// A verifier that counts what it was asked to check.
|
||||
struct Counting<'a> {
|
||||
inner: &'a ValidatorSet,
|
||||
pairings: Cell<usize>,
|
||||
}
|
||||
impl VoteVerifier for Counting<'_> {
|
||||
fn verify_vote(&self, n: &NodeId, m: &[u8], sig: &[u8], h: u64) -> bool {
|
||||
self.pairings.set(self.pairings.get() + 1);
|
||||
self.inner.verify_vote(n, m, sig, h)
|
||||
}
|
||||
}
|
||||
|
||||
let (s, set) = committee(4, 1);
|
||||
let counting = Counting {
|
||||
inner: &set,
|
||||
pairings: Cell::new(0),
|
||||
};
|
||||
let v = s[0].vote(&position(), true);
|
||||
|
||||
let mut t = tally(Finality::Quasar);
|
||||
assert!(t.add(&v, &counting).expect("first"));
|
||||
assert_eq!(counting.pairings.get(), 1, "the first vote is verified");
|
||||
|
||||
for _ in 0..16 {
|
||||
assert!(!t.add(&v, &counting).expect("a replay is not an error"));
|
||||
}
|
||||
assert_eq!(counting.pairings.get(), 1, "and no copy of it is");
|
||||
|
||||
// Including a copy carrying garbage where the signature was: a held signer's
|
||||
// second frame is dropped on the lookup, so it never reaches the curve.
|
||||
let mut forged = v.clone();
|
||||
forged.signature = vec![0u8; 96];
|
||||
assert!(!t.add(&forged, &counting).expect("still just a replay"));
|
||||
assert_eq!(counting.pairings.get(), 1);
|
||||
assert_eq!(t.len(), 1);
|
||||
|
||||
// What the tally holds is the FIRST signature, not the last frame — which is
|
||||
// why dropping the copy unverified loses nothing.
|
||||
for signer in s.iter().skip(1) {
|
||||
assert!(t
|
||||
.add(&signer.vote(&position(), true), &counting)
|
||||
.expect("held"));
|
||||
}
|
||||
let cert = t.cert(&set, &set).expect("four of four exports");
|
||||
assert_eq!(
|
||||
cert.votes[0].signature, v.signature,
|
||||
"the first signature stands"
|
||||
);
|
||||
}
|
||||
|
||||
/// Below four signers the fault budget f = ⌊(n−1)/3⌋ is zero: every signer is
|
||||
/// load-bearing and a single compromised key forges the export. A unanimous
|
||||
/// three-signer set clears both other floors and still cannot export.
|
||||
#[test]
|
||||
fn a_tally_cannot_export_below_the_minimum_committee() {
|
||||
let (s, set) = committee(3, 1);
|
||||
let mut t = tally(Finality::Quasar);
|
||||
for signer in &s {
|
||||
assert!(t.add(&signer.vote(&position(), true), &set).expect("held"));
|
||||
}
|
||||
assert_eq!(
|
||||
t.cert(&set, &set),
|
||||
Err(VoteError::Cert(CertError::MinCommittee { n: 3, need: 4 })),
|
||||
"unanimity over three is not a Byzantine supermajority",
|
||||
);
|
||||
}
|
||||
|
||||
/// Nova is a different rung with different floors, and a tally runs whichever it
|
||||
/// opened at — it does not carry one rule and label it two.
|
||||
#[test]
|
||||
fn a_nova_tally_runs_the_nova_floors() {
|
||||
let (s, set) = committee(7, 1);
|
||||
assert_eq!(nova_signer_floor(7), 3);
|
||||
assert_eq!(half_stake_floor(7), 3);
|
||||
|
||||
let mut t = tally(Finality::Nova);
|
||||
for signer in s.iter().take(3) {
|
||||
assert!(t.add(&signer.vote(&position(), true), &set).expect("held"));
|
||||
}
|
||||
// Three clears the signer floor and ties the stake majority, which must be
|
||||
// STRICTLY exceeded.
|
||||
assert_eq!(
|
||||
t.cert(&set, &set),
|
||||
Err(VoteError::Cert(CertError::StakeBelowMajority {
|
||||
voted: 3,
|
||||
signer: 7,
|
||||
need_above: 3
|
||||
})),
|
||||
);
|
||||
|
||||
assert!(t.add(&s[3].vote(&position(), true), &set).expect("held"));
|
||||
let cert = t.cert(&set, &set).expect("four of seven ignites");
|
||||
assert_eq!(cert.tier, Finality::Nova);
|
||||
|
||||
// And Nova's four is not Quasar's five: the same votes at the export rung
|
||||
// are refused.
|
||||
let mut q = tally(Finality::Quasar);
|
||||
for signer in s.iter().take(4) {
|
||||
assert!(q.add(&signer.vote(&position(), true), &set).expect("held"));
|
||||
}
|
||||
assert!(
|
||||
q.cert(&set, &set).is_err(),
|
||||
"the export rung is not the ignition rung"
|
||||
);
|
||||
}
|
||||
|
||||
/// A rogue key — `g1·x − Σ pk_others`, the key whose holder knows no secret —
|
||||
/// cannot be registered, so it never becomes a signer and never reaches a tally.
|
||||
/// The proof of possession is what closes it, and it is closed at the door.
|
||||
#[test]
|
||||
fn a_rogue_key_never_reaches_a_tally() {
|
||||
let (s, mut set) = committee(4, 1);
|
||||
let rogue_id = {
|
||||
let mut id = [0u8; NODE_LEN];
|
||||
id[0] = 0xAA;
|
||||
id
|
||||
};
|
||||
|
||||
// No secret is known for this key, so no proof over (node, key) can be made;
|
||||
// any bytes offered as one are refused.
|
||||
let honest = s[0].public();
|
||||
assert_eq!(
|
||||
set.insert(rogue_id, 1, &honest, &[0u8; 96]),
|
||||
Err(CertError::PopInvalid),
|
||||
"a proof that is not a signature is not a proof",
|
||||
);
|
||||
// Nor can it be smuggled in behind an honest validator's own proof: a proof
|
||||
// binds the one (node, key) pair it was made for.
|
||||
assert_eq!(
|
||||
set.insert(rogue_id, 1, &honest, &s[0].pop()),
|
||||
Err(CertError::PopInvalid),
|
||||
"a proof made for another node does not travel",
|
||||
);
|
||||
|
||||
// So the identity resolves to nothing, and a tally holds nothing for it.
|
||||
let mut t = tally(Finality::Quasar);
|
||||
let forged = SignedVote {
|
||||
position: position(),
|
||||
accept: true,
|
||||
node: rogue_id,
|
||||
signature: vec![0u8; 96],
|
||||
};
|
||||
assert_eq!(t.add(&forged, &set), Err(VoteError::Signature));
|
||||
}
|
||||
|
||||
// ── What a certificate a tally issues is worth ──────────────────────
|
||||
|
||||
/// The certificate a tally hands out is not a private artefact: it passes the
|
||||
/// same predicate any third party runs, and survives its own encoding.
|
||||
#[test]
|
||||
fn the_certificate_a_tally_issues_verifies_for_a_third_party() {
|
||||
let (s, set) = committee(7, 1);
|
||||
let mut t = tally(Finality::Quasar);
|
||||
for signer in s.iter().take(5) {
|
||||
assert!(t.add(&signer.vote(&position(), true), &set).expect("held"));
|
||||
}
|
||||
let cert = t.cert(&set, &set).expect("a quorum certifies");
|
||||
assert_eq!(i64::from(cert.threshold), signer_floor(Finality::Quasar, 7));
|
||||
|
||||
// A party that never saw the tally, holding only the set.
|
||||
cert.verify_weighted(&set, &set, EPOCH)
|
||||
.expect("it verifies for anyone");
|
||||
|
||||
// Votes are in canonical order, which is the distinctness clause too.
|
||||
let ids: Vec<NodeId> = cert.votes.iter().map(|v| v.node_id).collect();
|
||||
let mut sorted = ids.clone();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
assert_eq!(ids, sorted, "strictly increasing node ids");
|
||||
}
|
||||
|
||||
/// One tally, one epoch. The height is fixed when the tally opens, and BOTH the
|
||||
/// signature check and the stake read use it — a tally cannot verify against one
|
||||
/// set and weigh against another.
|
||||
#[test]
|
||||
fn a_tally_reads_one_epoch_on_both_sides() {
|
||||
/// A set that exists at exactly one height. Outside it there are no keys and
|
||||
/// no stake, which is what an epoch boundary looks like to a reader.
|
||||
struct AtEpoch<'a> {
|
||||
inner: &'a ValidatorSet,
|
||||
epoch: u64,
|
||||
}
|
||||
impl VoteVerifier for AtEpoch<'_> {
|
||||
fn verify_vote(&self, n: &NodeId, m: &[u8], s: &[u8], h: u64) -> bool {
|
||||
h == self.epoch && self.inner.verify_vote(n, m, s, h)
|
||||
}
|
||||
}
|
||||
impl StakeSource for AtEpoch<'_> {
|
||||
fn weight(&self, n: &NodeId, h: u64) -> u64 {
|
||||
if h == self.epoch {
|
||||
self.inner.weight(n, h)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
fn signer_stake(&self, h: u64) -> u64 {
|
||||
if h == self.epoch {
|
||||
self.inner.signer_stake(h)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
fn signer_count(&self, h: u64) -> i64 {
|
||||
if h == self.epoch {
|
||||
self.inner.signer_count(h)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (s, set) = committee(7, 1);
|
||||
let live = AtEpoch {
|
||||
inner: &set,
|
||||
epoch: EPOCH,
|
||||
};
|
||||
|
||||
// At the epoch the tally opened at, everything resolves.
|
||||
let mut here = Tally::new(position(), Finality::Quasar, EPOCH).expect("tally");
|
||||
for signer in s.iter().take(5) {
|
||||
assert!(here
|
||||
.add(&signer.vote(&position(), true), &live)
|
||||
.expect("held"));
|
||||
}
|
||||
here.cert(&live, &live).expect("a quorum at its own epoch");
|
||||
|
||||
// A tally opened at another height reads that height on the SIGNATURE side:
|
||||
// no key resolves there, so nothing is even held, and the refusal is the
|
||||
// signature clause rather than a quorum one.
|
||||
let mut elsewhere = Tally::new(position(), Finality::Quasar, EPOCH + 1).expect("tally");
|
||||
assert_eq!(elsewhere.epoch_height(), EPOCH + 1);
|
||||
assert_eq!(
|
||||
elsewhere.add(&s[0].vote(&position(), true), &live),
|
||||
Err(VoteError::Signature),
|
||||
"a signature is checked at the tally's epoch",
|
||||
);
|
||||
|
||||
// And it reads that height on the STAKE side, which the same tally cannot
|
||||
// show — it holds nothing to weigh. So: a tally that DID hold votes, handed
|
||||
// a source alive only at some other epoch. The stake read is the tally's
|
||||
// epoch, so the source answers zero and the certificate fails closed.
|
||||
let dead = AtEpoch {
|
||||
inner: &set,
|
||||
epoch: EPOCH + 1,
|
||||
};
|
||||
assert_eq!(
|
||||
here.cert(&live, &dead),
|
||||
Err(VoteError::Cert(CertError::StakeZero {
|
||||
epoch_height: EPOCH
|
||||
})),
|
||||
"the stake is read at the tally's epoch, not at the source's",
|
||||
);
|
||||
}
|
||||
|
||||
// ── The transport seam ──────────────────────────────────────────────
|
||||
|
||||
/// Consensus states the shape and opens no socket. A test carries a vote over a
|
||||
/// vector; a node carries it over a mesh; the tally cannot tell.
|
||||
#[test]
|
||||
fn the_transport_is_a_seam_the_caller_fills() {
|
||||
use std::cell::RefCell;
|
||||
|
||||
struct Echo(RefCell<Vec<SignedVote>>);
|
||||
impl VoteTransport for Echo {
|
||||
fn broadcast(&self, vote: &SignedVote) {
|
||||
self.0.borrow_mut().push(vote.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let (s, set) = committee(4, 1);
|
||||
let wire = Echo(RefCell::new(Vec::new()));
|
||||
for signer in &s {
|
||||
wire.broadcast(&signer.vote(&position(), true));
|
||||
}
|
||||
|
||||
// An implementation may echo a vote back to its origin; the tally
|
||||
// deduplicates by signer, so a self-echo costs nothing.
|
||||
let mut t = tally(Finality::Quasar);
|
||||
let sent = wire.0.borrow();
|
||||
for v in sent.iter().chain(sent.iter()) {
|
||||
t.add(v, &set).expect("held or already held");
|
||||
}
|
||||
assert_eq!(t.len(), 4, "four signers, however many times each spoke");
|
||||
}
|
||||
|
||||
/// A certificate assembled outside a tally is subject to the same rule. There is
|
||||
/// no second door: `QuorumCert::assemble` still produces something, and it is
|
||||
/// `verify_weighted` that refuses it — which is exactly what `Tally::cert` runs.
|
||||
#[test]
|
||||
fn the_rule_is_the_same_rule_outside_a_tally() {
|
||||
let (s, set) = committee(7, 1);
|
||||
let pos = position();
|
||||
let votes: Vec<_> = s
|
||||
.iter()
|
||||
.take(2)
|
||||
.map(|x| x.vote(&pos, true).record())
|
||||
.collect();
|
||||
let loose = QuorumCert::assemble(Finality::Quasar, pos, 2, &votes).expect("assembles");
|
||||
assert!(
|
||||
loose.verify_weighted(&set, &set, EPOCH).is_err(),
|
||||
"assembling is not accepting, here as anywhere",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user