What we’re building
Proof of stake replaces “burn electricity to earn the right to propose” with “lock economic value to earn the right to propose.” A validator’s influence — how often it proposes, how much its vote counts — is proportional to its stake. Misbehavior is punished by slashing that stake.
In this lesson we build the consensus core, not a networked node: a validator set with stakes, deterministic stake-weighted proposer selection, a 2/3-stake finality vote, and the security intuition behind slashing. Everything compiles and is covered by tests. This is a deliberately simplified teaching model — production protocols (Ethereum’s Gasper, CometBFT/Tendermint) add fork choice, multi-round locking, view changes, and evidence gossip that we intentionally omit.
One dependency, for hashing:
[package]
name = "pos_lesson"
version = "0.1.0"
edition = "2021"
[dependencies]
sha2 = "0.10"The validator set
A validator is an identity plus a stake. The set tracks total stake, because almost every PoS calculation — proposer odds, the finality threshold — is a ratio against total stake.
use sha2::{Digest, Sha256};
pub type ValidatorId = u32;
#[derive(Clone, Debug)]
pub struct Validator {
pub id: ValidatorId,
pub stake: u64,
}
#[derive(Clone, Debug)]
pub struct ValidatorSet {
validators: Vec<Validator>,
total_stake: u64,
}
impl ValidatorSet {
pub fn new(mut validators: Vec<Validator>) -> Self {
validators.sort_by_key(|v| v.id);
let total_stake = validators.iter().map(|v| v.stake).sum();
Self { validators, total_stake }
}
pub fn total_stake(&self) -> u64 {
self.total_stake
}
}The sort_by_key is not cosmetic — it is a consensus requirement. Every validator must iterate the set in the same order, or stake-weighted selection (next section) produces different winners on different nodes. Determinism starts here: a ValidatorSet built from the same validators must be byte-for-byte identical everywhere. Never store validators in a HashMap and iterate it in a consensus path; hash-map order is unspecified.
Why stake-weighting resists Sybil attacks
A Sybil attack is creating many fake identities to gain disproportionate influence. In a one-node-one-vote system, an attacker spins up 10,000 nodes and wins. PoS defeats this by tying influence to stake, not identity count: splitting 1,000 tokens across 10,000 identities gives you the same total influence as holding them in one. Influence is bought with capital, and capital is what’s at risk — that’s the whole game.
Deterministic, stake-weighted proposer selection
For each slot we must pick exactly one proposer, with probability proportional to stake. The naive instinct is rand::random(). Do not do this. Consensus code must be deterministic: every honest validator has to independently compute the same proposer for slot N, with no communication. If selection used real randomness, nodes would disagree on who’s allowed to propose, and the chain would fork instantly. Reproducibility also makes the logic testable and auditable.
So we derive selection from a seed (in a real chain, an on-chain value like a RANDAO mix or the previous block hash — agreed by everyone) combined with the slot number. We hash seed || slot, reduce the digest modulo total stake, and walk the cumulative-stake intervals. Each validator owns an interval sized to its stake, so the draw lands in validator v’s slice with probability stake(v) / total_stake.
impl ValidatorSet {
pub fn select_proposer(&self, seed: &[u8; 32], slot: u64) -> ValidatorId {
// Deterministic draw: hash(seed || slot). No RNG, no clock, no I/O.
let mut hasher = Sha256::new();
hasher.update(seed);
hasher.update(slot.to_le_bytes());
let digest = hasher.finalize();
// Map the first 8 bytes into [0, total_stake).
let mut acc = [0u8; 8];
acc.copy_from_slice(&digest[0..8]);
let draw = u64::from_le_bytes(acc) % self.total_stake;
// Walk cumulative stake; the interval containing `draw` wins.
let mut cumulative = 0u64;
for v in &self.validators {
cumulative += v.stake;
if draw < cumulative {
return v.id;
}
}
unreachable!("draw < total_stake guarantees a winner")
}
}This is weighted selection by interval. Validator 0 owns [0, 50), validator 1 owns [50, 80), and so on. Because draw is uniform over [0, total_stake), the chance of landing in a given interval equals that interval’s width over the total — exactly the stake share. Using to_le_bytes (not native-endian) pins the byte layout so the result is identical on big- and little-endian machines.
A subtle note on % total_stake: for small sets and tiny stakes this introduces negligible modulo bias; production systems use rejection sampling or a wider draw to eliminate it. For a teaching model with realistic stake magnitudes, it’s fine, and the test below proves the distribution holds.
Proving stake-weighting works
Determinism means we can test the distribution — run many slots and assert the selection frequency converges to each validator’s stake share. This is the test that catches a broken weighting (e.g. forgetting to accumulate, or off-by-one on the interval check).
#[cfg(test)]
mod tests {
use super::*;
fn sample_set() -> ValidatorSet {
ValidatorSet::new(vec![
Validator { id: 0, stake: 50 },
Validator { id: 1, stake: 30 },
Validator { id: 2, stake: 15 },
Validator { id: 3, stake: 5 },
])
}
#[test]
fn selection_is_deterministic() {
let set = sample_set();
let seed = [7u8; 32];
assert_eq!(set.select_proposer(&seed, 42), set.select_proposer(&seed, 42));
}
#[test]
fn selection_frequency_tracks_stake() {
let set = sample_set();
let seed = [42u8; 32];
let slots = 100_000u64;
let mut counts = [0u64; 4];
for slot in 0..slots {
counts[set.select_proposer(&seed, slot) as usize] += 1;
}
// Stakes are 50/30/15/5 of 100 → expected shares.
let expected = [0.50, 0.30, 0.15, 0.05];
for id in 0..4 {
let observed = counts[id] as f64 / slots as f64;
assert!(
(observed - expected[id]).abs() < 0.01,
"validator {id}: observed {observed:.4}, expected {:.4}",
expected[id]
);
}
}
}Over 100,000 slots, observed frequencies land within 1% of stake share — validator 0 proposes ~50% of the time, validator 3 ~5%. The first test confirms the same seed + slot always yields the same proposer: the determinism property consensus depends on.
Block proposal and the 2/3 finality vote
The selected proposer builds a block. Ours is minimal — a slot, the proposer id, and the parent hash — but it hashes deterministically, which is what attestation needs (validators sign the hash).
pub struct Block {
pub slot: u64,
pub proposer: ValidatorId,
pub parent_hash: [u8; 32],
}
impl Block {
pub fn hash(&self) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(self.slot.to_le_bytes());
hasher.update(self.proposer.to_le_bytes());
hasher.update(self.parent_hash);
hasher.finalize().into()
}
}Now finality. Validators attest (vote) for a block by signing its hash. We tally the stake behind those votes — not the head count — and the block is final once supporting stake crosses two-thirds of total stake.
impl ValidatorSet {
pub fn quorum_threshold(&self) -> u64 {
self.total_stake * 2 / 3 + 1
}
}
#[derive(Default)]
pub struct VoteTally {
voted: std::collections::HashSet<ValidatorId>,
stake_for: u64,
}
impl VoteTally {
pub fn cast(&mut self, voter: ValidatorId, stake: u64) {
// HashSet makes double-counting a duplicate vote impossible.
if self.voted.insert(voter) {
self.stake_for += stake;
}
}
pub fn is_final(&self, set: &ValidatorSet) -> bool {
self.stake_for >= set.quorum_threshold()
}
}The HashSet guard matters: a malicious or buggy peer that re-sends a vote must not inflate the tally. Idempotent vote handling is a real-world correctness requirement, not a nicety.
#[test]
fn finality_needs_two_thirds() {
let set = sample_set(); // total stake = 100, threshold = 67
let mut tally = VoteTally::default();
tally.cast(0, 50);
assert!(!tally.is_final(&set)); // 50 < 67, not final
tally.cast(1, 30);
assert!(tally.is_final(&set)); // 80 >= 67, final
tally.cast(1, 30); // duplicate vote ignored
assert_eq!(tally.stake_for, 80);
}Why two-thirds, specifically?
The 2/3 threshold is the heart of Byzantine fault tolerance. Assume up to f of the stake is Byzantine (malicious or faulty). To guarantee that no two conflicting blocks can both reach quorum, and that the chain can still make progress, the math requires total stake n > 3f — equivalently, you tolerate strictly less than 1/3 Byzantine stake, and require more than 2/3 to finalize. The CometBFT consensus spec states it directly: a block commits on +2/3 voting power, and safety holds as long as less than 1/3 of voting power is Byzantine.
The intuition: two conflicting blocks each finalized would each need >2/3 of the stake. Two sets that are each >2/3 of the whole must overlap by more than 1/3 — and that overlap is honest validators voting for both, which honest validators never do. So conflicting finality is impossible unless >1/3 of stake is dishonest. That’s why an attacker needs to corrupt a third of all staked value to even threaten safety — and on a real chain, that’s an enormous amount of capital at risk of being slashed.
Slashing: the honest note
We tallied votes but never punished bad ones. Slashing is what makes the 2/3 guarantee economically real: misbehavior burns stake. The canonical slashable offense is equivocation — signing two different blocks (or two contradictory attestations) for the same slot. That’s precisely the move an attacker uses to try to finalize two conflicting chains.
Detection is conceptually simple: collect signed votes, and if you ever see two valid signatures from the same validator over different block hashes at the same slot, you have cryptographic evidence of equivocation — an unforgeable proof anyone can verify. Ethereum slashes a portion of the offender’s stake (scaled up by a correlation penalty when many validators misbehave together) and ejects them; CometBFT’s fork-accountability spec formalizes how such evidence is gathered and attributed.
We deliberately stop short of implementing it. A real slashing protocol needs signature verification, an evidence pool, gossip of evidence, replay protection, and a penalty schedule — each a lesson of its own. The takeaway: stake-weighting decides who proposes and how much their vote weighs; the 2/3 threshold decides when a block is final; slashing is the economic enforcement that makes lying more expensive than it could ever be worth. Remove slashing and PoS degrades to a popularity contest with no teeth.
Where to go next
This core is real but bare. Production systems layer on: a fork-choice rule (which chain is canonical when you see competing blocks), multi-round voting with locking so validators don’t flip-flop under partial network views, signature-verified attestations over our trust-the-caller tally, and the full slashing pipeline sketched above. Build each on top of the deterministic, testable foundation here — and keep every consensus path free of rand, clocks, and hash-map iteration order. Determinism is not a style preference in consensus; it is the property the whole system rests on. Next we connect validators across the network with P2P gossip.