Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions contracts/teachlink/EVENT_SCHEMA.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
129 changes: 103 additions & 26 deletions contracts/teachlink/src/bft_consensus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
//!
Expand Down Expand Up @@ -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<Address, i128> = 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);
Expand Down Expand Up @@ -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<Address, bool> = env
.storage()
Expand All @@ -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
};
Expand Down Expand Up @@ -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);
}
}
6 changes: 4 additions & 2 deletions contracts/teachlink/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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]
Expand Down
35 changes: 31 additions & 4 deletions contracts/teachlink/src/property_based_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
12 changes: 9 additions & 3 deletions contracts/teachlink/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,12 @@ pub struct BridgeProposal {
pub proposal_id: u64,
pub message: CrossChainMessage,
pub votes: Map<Address, bool>,
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,
Expand All @@ -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,
}

Expand Down
Loading