From d4959d33fc707ab0115adf16f5c53931d446b712 Mon Sep 17 00:00:00 2001 From: zakariyaufarida5-wq Date: Mon, 17 Aug 2026 21:50:52 +0100 Subject: [PATCH 1/2] fix(bft): stake-weight consensus threshold and vote counting (#496) update_consensus_state derived the Byzantine threshold from the active validator *count* (floor(2n/3)+1), so an attacker could cheapen a Sybil attack by registering many low-stake validators. Weight the threshold and the vote tally by stake instead: - byzantine_threshold = floor(2 * total_stake / 3) + 1 (stake units) - vote_on_proposal adds the voting validator's stake to vote_count rather than a flat +1, so consensus requires approving validators to jointly control more than 2/3 of the total staked value - vote_count / required_votes / byzantine_threshold widened to i128 - property tests updated to the stake-weighted formula and a new Sybil-resistance property; new unit test shows many low-stake validators cannot cheaply reach the threshold --- contracts/teachlink/EVENT_SCHEMA.md | 4 +- contracts/teachlink/src/bft_consensus.rs | 129 ++++++++++++++---- contracts/teachlink/src/events.rs | 6 +- .../teachlink/src/property_based_tests.rs | 32 ++++- contracts/teachlink/src/types.rs | 12 +- 5 files changed, 146 insertions(+), 37 deletions(-) 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..bdaae4c8 100644 --- a/contracts/teachlink/src/property_based_tests.rs +++ b/contracts/teachlink/src/property_based_tests.rs @@ -6,13 +6,37 @@ 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: because the threshold scales with total stake, an + // adversary that controls at most 2/3 of the stake can never reach + // quorum, no matter how many low-stake validators it splits that stake + // across (i.e. validator *count* buys no advantage). + #[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; + // Model an adversary holding no more than 2/3 of the total stake. + prop_assume!(adversary_stake.saturating_mul(3) <= total_stake.saturating_mul(2)); + // Such an adversary is always strictly below the quorum threshold, + // regardless of how many Sybil validators the stake is spread over. + prop_assert!(adversary_stake < threshold); } #[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, } From 4a992d29a7d2d49b0d6b70f7c35af1cacceea6c3 Mon Sep 17 00:00:00 2001 From: zakariyaufarida5-wq Date: Mon, 17 Aug 2026 21:58:59 +0100 Subject: [PATCH 2/2] test(bft): make the Sybil-resistance property total (#496) The stake_threshold_resists_sybil_count property used prop_assume! to keep the adversary's stake below 2/3 of the total, but that condition rejects the large majority of generated inputs, so proptest aborted with too many rejects. Replace the assumption with an equivalent tautology that holds for every input: the adversary is either below the quorum threshold or genuinely controls more than 2/3 of the total stake. No prop_assume, no rejections. --- .../teachlink/src/property_based_tests.rs | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/contracts/teachlink/src/property_based_tests.rs b/contracts/teachlink/src/property_based_tests.rs index bdaae4c8..8e98a5b7 100644 --- a/contracts/teachlink/src/property_based_tests.rs +++ b/contracts/teachlink/src/property_based_tests.rs @@ -19,10 +19,11 @@ mod tests { prop_assert!(threshold <= total_stake); } - // Sybil resistance: because the threshold scales with total stake, an - // adversary that controls at most 2/3 of the stake can never reach - // quorum, no matter how many low-stake validators it splits that stake - // across (i.e. validator *count* buys no advantage). + // 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, @@ -32,11 +33,13 @@ mod tests { 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; - // Model an adversary holding no more than 2/3 of the total stake. - prop_assume!(adversary_stake.saturating_mul(3) <= total_stake.saturating_mul(2)); - // Such an adversary is always strictly below the quorum threshold, - // regardless of how many Sybil validators the stake is spread over. - prop_assert!(adversary_stake < threshold); + // 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]