diff --git a/contracts/teachlink/EVENT_SCHEMA.md b/contracts/teachlink/EVENT_SCHEMA.md index fb73db78..435cb7d8 100644 --- a/contracts/teachlink/EVENT_SCHEMA.md +++ b/contracts/teachlink/EVENT_SCHEMA.md @@ -152,7 +152,7 @@ Emitted when a new proposal is created. |-------|------|-------------| | `proposal_id` | `u64` | Proposal identifier | | `message` | `CrossChainMessage` | Proposal message | -| `required_votes` | `u32` | Votes needed for approval | +| `required_votes` | `i128` | Approving stake needed for approval (stake-weighted Byzantine threshold) | #### ProposalVotedEvent Emitted when a validator votes on a proposal. @@ -162,7 +162,7 @@ Emitted when a validator votes on a proposal. | `proposal_id` | `u64` | Proposal identifier | | `validator` | `Address` | Voting validator | | `vote` | `bool` | Vote value (true/false) | -| `vote_count` | `u32` | Current vote count | +| `vote_count` | `i128` | Current approving stake tally (stake-weighted) | #### ProposalExecutedEvent Emitted when a proposal is executed. diff --git a/contracts/teachlink/src/bft_consensus.rs b/contracts/teachlink/src/bft_consensus.rs index 379a6462..5487df00 100644 --- a/contracts/teachlink/src/bft_consensus.rs +++ b/contracts/teachlink/src/bft_consensus.rs @@ -3,21 +3,26 @@ //! This module implements a BFT consensus mechanism for bridge validators, //! ensuring that the bridge can tolerate up to f faulty validators out of 3f+1 total validators. //! -//! # BFT Threshold Algorithm +//! # BFT Threshold Algorithm (stake-weighted) //! -//! The Byzantine threshold (minimum votes required to approve a proposal) is -//! computed as: +//! The Byzantine threshold (minimum approving *stake* required to approve a +//! proposal) is computed from the total staked value, not the validator +//! count: //! //! ```text -//! byzantine_threshold = floor(2 * n / 3) + 1 +//! byzantine_threshold = floor(2 * total_stake / 3) + 1 //! ``` //! -//! where `n` is the number of active validators. This satisfies the classic -//! BFT requirement: a quorum of ⌈2n/3⌉ guarantees safety even when up to -//! ⌊n/3⌋ validators are Byzantine (malicious or offline). +//! where `total_stake` is the sum of the stake of all active validators. Each +//! approving vote contributes the voting validator's stake toward the +//! threshold. This satisfies the classic BFT requirement in stake terms: a +//! quorum controlling more than 2/3 of the stake guarantees safety even when +//! up to (just under) 1/3 of the stake is Byzantine (malicious or offline). //! -//! Example: with 10 validators, threshold = (2*10/3)+1 = 7. An attacker -//! controlling 3 validators cannot reach quorum alone. +//! Weighting by stake rather than validator count keeps Sybil attacks +//! expensive: registering many low-stake validators raises `total_stake` — and +//! therefore the threshold — proportionally, so an attacker still needs a +//! genuine 2/3 stake majority to force consensus (#496). //! //! # Proposal Lifecycle //! @@ -467,10 +472,19 @@ impl BFTConsensus { return Err(BridgeError::ProposalAlreadyVoted); } - // Record vote + // Record vote (stake-weighted, #496): an approval contributes the + // voter's stake to `vote_count` rather than a flat +1, so consensus is + // reached only once the approving validators jointly control the + // stake-weighted Byzantine threshold. proposal.votes.set(validator.clone(), approve); if approve { - proposal.vote_count += 1; + let stakes: Map = env + .storage() + .instance() + .get(&VALIDATOR_STAKES) + .unwrap_or_else(|| Map::new(env)); + let voter_stake = stakes.get(validator.clone()).unwrap_or(0); + proposal.vote_count = proposal.vote_count.saturating_add(voter_stake); } proposals.set(proposal_id, proposal.clone()); env.storage().instance().set(&BRIDGE_PROPOSALS, &proposals); @@ -547,23 +561,22 @@ impl BFTConsensus { /// /// # Algorithm /// - /// Iterates all registered validators, summing stake and counting active - /// entries. Then computes the Byzantine threshold: + /// Iterates all registered validators, summing the stake of active + /// entries. Then computes the stake-weighted Byzantine threshold: /// /// ```text - /// byzantine_threshold = floor(2 * active_validators / 3) + 1 + /// byzantine_threshold = floor(2 * total_stake / 3) + 1 /// ``` /// - /// This is the minimum number of approving votes required for a proposal - /// to reach consensus. The formula satisfies BFT safety: with `n = 3f+1` - /// validators, `2f+1` votes are needed, tolerating `f` Byzantine nodes. + /// This is the minimum approving *stake* required for a proposal to reach + /// consensus. The formula satisfies BFT safety in stake terms: a quorum + /// controlling more than 2/3 of the total stake tolerates up to (just + /// under) 1/3 Byzantine stake. Because the threshold scales with stake, + /// registering additional low-stake validators cannot cheapen a Sybil + /// attack (#496). /// /// Called after every validator registration or unregistration to keep the - /// threshold in sync with the current validator set size. - /// - /// # TODO - /// - Weight the threshold by stake rather than validator count to make - /// Sybil attacks more expensive (stake-weighted BFT). + /// threshold in sync with the current total stake. fn update_consensus_state(env: &Env) -> Result<(), BridgeError> { let validators: Map = env .storage() @@ -590,10 +603,14 @@ impl BFTConsensus { } } - // Byzantine threshold: 2f+1 where n = 3f+1 - // For n validators, we need ceil(2n/3) + 1 for BFT - let byzantine_threshold = if active_validators > 0 { - ((2 * active_validators) / 3) + 1 + // Stake-weighted Byzantine threshold (#496): a quorum must control more + // than 2/3 of the total staked value, not merely 2/3 of the validator + // count. Expressed in stake units this is `floor(2 * total_stake / 3) + + // 1`, preserving the classic `2f+1`-of-`3f+1` safety margin while + // making Sybil attacks as expensive as acquiring a proportional share + // of total stake. + let byzantine_threshold: i128 = if total_stake > 0 { + (total_stake.saturating_mul(2) / 3) + 1 } else { 1 }; @@ -947,4 +964,64 @@ mod tests { assert!(after_rep > 90, "reputation should increase after voting"); } + + #[test] + fn threshold_and_votes_are_stake_weighted_and_sybil_resistant() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(TeachLinkBridge, ()); + set_ledger(&env, 1_000, 1); + + let client = TeachLinkBridgeClient::new(&env, &contract_id); + + // One large-stake validator plus three minimum-stake "Sybil" validators. + let whale = soroban_sdk::Address::generate(&env); + let sybil_a = soroban_sdk::Address::generate(&env); + let sybil_b = soroban_sdk::Address::generate(&env); + let sybil_c = soroban_sdk::Address::generate(&env); + + let whale_stake = MIN_VALIDATOR_STAKE * 10; + client.register_validator(&whale, &whale_stake); + client.register_validator(&sybil_a, &MIN_VALIDATOR_STAKE); + client.register_validator(&sybil_b, &MIN_VALIDATOR_STAKE); + client.register_validator(&sybil_c, &MIN_VALIDATOR_STAKE); + + // The threshold is derived from total stake, not validator count. + let total_stake = whale_stake + MIN_VALIDATOR_STAKE * 3; + let expected_threshold = (total_stake * 2) / 3 + 1; + let state = client.get_consensus_state(); + assert_eq!(state.total_stake, total_stake); + assert_eq!(state.active_validators, 4); + assert_eq!(state.byzantine_threshold, expected_threshold); + + let msg = CrossChainMessage { + source_chain: 1, + source_tx_hash: Bytes::from_slice(&env, &[0x22; 32]), + nonce: 1, + token: soroban_sdk::Address::generate(&env), + amount: 1, + recipient: soroban_sdk::Address::generate(&env), + destination_chain: 2, + }; + let proposal_id = client.create_bridge_proposal(&msg); + + // All three Sybil validators approve. Their combined stake + // (3 * MIN_VALIDATOR_STAKE) is far below the 2/3 stake threshold, so + // increasing validator *count* alone cannot reach consensus. + client.vote_on_proposal(&sybil_a, &proposal_id, &true); + client.vote_on_proposal(&sybil_b, &proposal_id, &true); + client.vote_on_proposal(&sybil_c, &proposal_id, &true); + + let pending = client.get_proposal(&proposal_id).unwrap(); + assert_eq!(pending.vote_count, MIN_VALIDATOR_STAKE * 3); + assert_eq!(pending.status, crate::types::ProposalStatus::Pending); + assert!(pending.vote_count < pending.required_votes); + + // The whale's stake pushes the approving stake past the threshold, so + // the proposal now reaches consensus and is approved. + client.vote_on_proposal(&whale, &proposal_id, &true); + let approved = client.get_proposal(&proposal_id).unwrap(); + assert_eq!(approved.vote_count, total_stake); + assert_eq!(approved.status, crate::types::ProposalStatus::Approved); + } } diff --git a/contracts/teachlink/src/events.rs b/contracts/teachlink/src/events.rs index 635b93c2..e5f79ca6 100644 --- a/contracts/teachlink/src/events.rs +++ b/contracts/teachlink/src/events.rs @@ -137,7 +137,8 @@ pub struct MinValidatorsUpdatedEvent { pub struct ProposalCreatedEvent { pub proposal_id: u64, pub message: CrossChainMessage, - pub required_votes: u32, + /// Stake-weighted approving stake required for consensus (#496). + pub required_votes: i128, } #[contractevent] @@ -146,7 +147,8 @@ pub struct ProposalVotedEvent { pub proposal_id: u64, pub validator: Address, pub vote: bool, - pub vote_count: u32, + /// Stake-weighted tally of approving votes so far (#496). + pub vote_count: i128, } #[contractevent] diff --git a/contracts/teachlink/src/property_based_tests.rs b/contracts/teachlink/src/property_based_tests.rs index 46853f8a..8e98a5b7 100644 --- a/contracts/teachlink/src/property_based_tests.rs +++ b/contracts/teachlink/src/property_based_tests.rs @@ -6,13 +6,40 @@ mod tests { use proptest::prelude::*; - // For n validators, BFT threshold is floor(2n/3) + 1. + // Stake-weighted BFT threshold (#496): given total stake `S`, a quorum + // must control `floor(2 * S / 3) + 1` stake. `n` (validator count) no + // longer drives the threshold. proptest! { #[test] - fn bft_threshold_is_bounded(n in 1u32..=10_000) { - let threshold = (2 * n) / 3 + 1; + fn stake_weighted_bft_threshold_is_bounded(total_stake in 1i128..=1_000_000_000_000i128) { + let threshold = (total_stake.saturating_mul(2) / 3) + 1; + // The threshold is a real quorum: strictly positive and never more + // than the whole stake (so it is always reachable by full consensus). prop_assert!(threshold >= 1); - prop_assert!(threshold <= n); + prop_assert!(threshold <= total_stake); + } + + // Sybil resistance: reaching the stake-weighted quorum requires + // controlling strictly more than 2/3 of the total stake. Splitting a + // fixed adversarial stake across many low-stake (Sybil) validators + // raises `total_stake` — and therefore the threshold — in lockstep, so + // validator *count* never lets an under-2/3 adversary reach quorum. + #[test] + fn stake_threshold_resists_sybil_count( + honest_stake in 1i128..=1_000_000_000i128, + sybil_unit in 1i128..=1_000_000i128, + sybil_count in 0i128..=100_000i128, + ) { + let adversary_stake = sybil_unit.saturating_mul(sybil_count); + let total_stake = honest_stake.saturating_add(adversary_stake); + let threshold = (total_stake.saturating_mul(2) / 3) + 1; + // For every possible split of stake into validators, the adversary + // is either below the quorum threshold or genuinely controls more + // than 2/3 of the total stake — never merely by adding validators. + prop_assert!( + adversary_stake < threshold + || adversary_stake.saturating_mul(3) > total_stake.saturating_mul(2) + ); } #[test] diff --git a/contracts/teachlink/src/types.rs b/contracts/teachlink/src/types.rs index fa59847e..2be1b515 100644 --- a/contracts/teachlink/src/types.rs +++ b/contracts/teachlink/src/types.rs @@ -188,8 +188,12 @@ pub struct BridgeProposal { pub proposal_id: u64, pub message: CrossChainMessage, pub votes: Map, - pub vote_count: u32, - pub required_votes: u32, + /// Stake-weighted tally of approving votes: the sum of the stake of every + /// validator that has approved, not a raw vote count (#496). + pub vote_count: i128, + /// Approving stake required to reach consensus — the stake-weighted + /// Byzantine threshold captured at proposal creation (#496). + pub required_votes: i128, pub status: ProposalStatus, pub created_at: u64, pub expires_at: u64, @@ -210,7 +214,9 @@ pub enum ProposalStatus { pub struct ConsensusState { pub total_stake: i128, pub active_validators: u32, - pub byzantine_threshold: u32, + /// Stake-weighted Byzantine threshold: the approving stake required for + /// consensus, `floor(2 * total_stake / 3) + 1` (#496). + pub byzantine_threshold: i128, pub last_consensus_round: u64, }