Skip to content

Commit 36d8702

Browse files
authored
Variable stake and randomized leader selection (#2638)
1 parent 78d7dd1 commit 36d8702

File tree

4 files changed

+188
-42
lines changed

4 files changed

+188
-42
lines changed

hotshot-example-types/src/node_types.rs

+2-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ pub use hotshot::traits::election::helpers::{
1111
};
1212
use hotshot::traits::{
1313
election::{
14-
helpers::QuorumFilterConfig, randomized_committee::RandomizedCommittee,
14+
helpers::QuorumFilterConfig, randomized_committee::Committee,
1515
randomized_committee_members::RandomizedCommitteeMembers,
1616
static_committee::StaticCommittee,
1717
static_committee_leader_two_views::StaticCommitteeLeaderForTwoViews,
@@ -97,7 +97,7 @@ impl NodeType for TestTypesRandomizedLeader {
9797
type Transaction = TestTransaction;
9898
type ValidatedState = TestValidatedState;
9999
type InstanceState = TestInstanceState;
100-
type Membership = RandomizedCommittee<TestTypesRandomizedLeader>;
100+
type Membership = Committee<TestTypesRandomizedLeader>;
101101
type BuilderSignatureKey = BuilderKey;
102102
}
103103

hotshot-types/src/drb.rs

+119
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,122 @@ impl<TYPES: NodeType> Default for DrbSeedsAndResults<TYPES> {
126126
Self::new()
127127
}
128128
}
129+
130+
/// Functions for leader selection based on the DRB.
131+
///
132+
/// The algorithm we use is:
133+
///
134+
/// Initialization:
135+
/// - obtain `drb: [u8; 32]` from the DRB calculation
136+
/// - sort the stake table for a given epoch by `xor(drb, public_key)`
137+
/// - generate a cdf of the cumulative stake using this newly-sorted table,
138+
/// along with a hash of the stake table entries
139+
///
140+
/// Selecting a leader:
141+
/// - calculate the SHA512 hash of the `drb_result`, `view_number` and `stake_table_hash`
142+
/// - find the first index in the cdf for which the remainder of this hash modulo the `total_stake`
143+
/// is strictly smaller than the cdf entry
144+
/// - return the corresponding node as the leader for that view
145+
pub mod election {
146+
use primitive_types::{U256, U512};
147+
use sha2::{Digest, Sha256, Sha512};
148+
149+
use crate::traits::signature_key::{SignatureKey, StakeTableEntryType};
150+
151+
/// Calculate `xor(drb.cycle(), public_key)`, returning the result as a vector of bytes
152+
fn cyclic_xor(drb: [u8; 32], public_key: Vec<u8>) -> Vec<u8> {
153+
let drb: Vec<u8> = drb.to_vec();
154+
155+
let mut result: Vec<u8> = vec![];
156+
157+
for (drb_byte, public_key_byte) in public_key.iter().zip(drb.iter().cycle()) {
158+
result.push(drb_byte ^ public_key_byte);
159+
}
160+
161+
result
162+
}
163+
164+
/// Generate the stake table CDF, as well as a hash of the resulting stake table
165+
pub fn generate_stake_cdf<Key: SignatureKey, Entry: StakeTableEntryType<Key>>(
166+
mut stake_table: Vec<Entry>,
167+
drb: [u8; 32],
168+
) -> RandomizedCommittee<Entry> {
169+
// sort by xor(public_key, drb_result)
170+
stake_table.sort_by(|a, b| {
171+
cyclic_xor(drb, a.public_key().to_bytes())
172+
.cmp(&cyclic_xor(drb, b.public_key().to_bytes()))
173+
});
174+
175+
let mut hasher = Sha256::new();
176+
177+
let mut cumulative_stake = U256::from(0);
178+
let mut cdf = vec![];
179+
180+
for entry in stake_table {
181+
cumulative_stake += entry.stake();
182+
hasher.update(entry.public_key().to_bytes());
183+
184+
cdf.push((entry, cumulative_stake));
185+
}
186+
187+
RandomizedCommittee {
188+
cdf,
189+
stake_table_hash: hasher.finalize().into(),
190+
drb,
191+
}
192+
}
193+
194+
/// select the leader for a view
195+
///
196+
/// # Panics
197+
/// Panics if `cdf` is empty. Results in undefined behaviour if `cdf` is not ordered.
198+
///
199+
/// Note that we try to downcast a U512 to a U256,
200+
/// but this should never panic because the U512 should be strictly smaller than U256::MAX by construction.
201+
pub fn select_randomized_leader<
202+
SignatureKey,
203+
Entry: StakeTableEntryType<SignatureKey> + Clone,
204+
>(
205+
randomized_committee: &RandomizedCommittee<Entry>,
206+
view: u64,
207+
) -> Entry {
208+
let RandomizedCommittee {
209+
cdf,
210+
stake_table_hash,
211+
drb,
212+
} = randomized_committee;
213+
// We hash the concatenated drb, view and stake table hash.
214+
let mut hasher = Sha512::new();
215+
hasher.update(drb);
216+
hasher.update(view.to_le_bytes());
217+
hasher.update(stake_table_hash);
218+
let raw_breakpoint: [u8; 64] = hasher.finalize().into();
219+
220+
// then calculate the remainder modulo the total stake as a U512
221+
let remainder: U512 =
222+
U512::from_little_endian(&raw_breakpoint) % U512::from(cdf.last().unwrap().1);
223+
224+
// and drop the top 32 bytes, downcasting to a U256
225+
let breakpoint: U256 = U256::try_from(remainder).unwrap();
226+
227+
// now find the first index where the breakpoint is strictly smaller than the cdf
228+
//
229+
// in principle, this may result in an index larger than `cdf.len()`.
230+
// however, we have ensured by construction that `breakpoint < total_stake`
231+
// and so the largest index we can actually return is `cdf.len() - 1`
232+
let index = cdf.partition_point(|(_, cumulative_stake)| breakpoint >= *cumulative_stake);
233+
234+
// and return the corresponding entry
235+
cdf[index].0.clone()
236+
}
237+
238+
#[derive(Clone, Debug)]
239+
pub struct RandomizedCommittee<Entry> {
240+
/// cdf of nodes by cumulative stake
241+
cdf: Vec<(Entry, U256)>,
242+
/// Hash of the stake table
243+
stake_table_hash: [u8; 32],
244+
/// DRB result
245+
drb: [u8; 32],
246+
}
247+
}

hotshot/src/traits/election/randomized_committee.rs

+19-15
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,27 @@
44
// You should have received a copy of the MIT License
55
// along with the HotShot repository. If not, see <https://mit-license.org/>.
66

7+
use std::{cmp::max, collections::BTreeMap, num::NonZeroU64};
8+
79
use hotshot_types::{
8-
drb::DrbResult,
10+
drb::{
11+
election::{generate_stake_cdf, select_randomized_leader, RandomizedCommittee},
12+
DrbResult,
13+
},
914
traits::{
1015
election::Membership,
1116
node_implementation::NodeType,
1217
signature_key::{SignatureKey, StakeTableEntryType},
1318
},
1419
PeerConfig,
1520
};
16-
use hotshot_utils::anytrace::Result;
21+
use hotshot_utils::anytrace::*;
1722
use primitive_types::U256;
18-
use rand::{rngs::StdRng, Rng};
19-
use std::{cmp::max, collections::BTreeMap, num::NonZeroU64};
2023

21-
#[derive(Clone, Debug, Eq, PartialEq, Hash)]
24+
#[derive(Clone, Debug)]
2225

2326
/// The static committee election
24-
pub struct RandomizedCommittee<T: NodeType> {
27+
pub struct Committee<T: NodeType> {
2528
/// The nodes eligible for leadership.
2629
/// NOTE: This is currently a hack because the DA leader needs to be the quorum
2730
/// leader but without voting rights.
@@ -33,6 +36,9 @@ pub struct RandomizedCommittee<T: NodeType> {
3336
/// The nodes on the committee and their stake
3437
da_stake_table: Vec<<T::SignatureKey as SignatureKey>::StakeTableEntry>,
3538

39+
/// Stake tables randomized with the DRB, used (only) for leader election
40+
randomized_committee: RandomizedCommittee<<T::SignatureKey as SignatureKey>::StakeTableEntry>,
41+
3642
/// The nodes on the committee and their stake, indexed by public key
3743
indexed_stake_table:
3844
BTreeMap<T::SignatureKey, <T::SignatureKey as SignatureKey>::StakeTableEntry>,
@@ -42,7 +48,7 @@ pub struct RandomizedCommittee<T: NodeType> {
4248
BTreeMap<T::SignatureKey, <T::SignatureKey as SignatureKey>::StakeTableEntry>,
4349
}
4450

45-
impl<TYPES: NodeType> Membership<TYPES> for RandomizedCommittee<TYPES> {
51+
impl<TYPES: NodeType> Membership<TYPES> for Committee<TYPES> {
4652
type Error = hotshot_utils::anytrace::Error;
4753

4854
/// Create a new election
@@ -91,10 +97,14 @@ impl<TYPES: NodeType> Membership<TYPES> for RandomizedCommittee<TYPES> {
9197
.map(|entry| (TYPES::SignatureKey::public_key(entry), entry.clone()))
9298
.collect();
9399

100+
// We use a constant value of `[0u8; 32]` for the drb, since this is just meant to be used in tests
101+
let randomized_committee = generate_stake_cdf(eligible_leaders.clone(), [0u8; 32]);
102+
94103
Self {
95104
eligible_leaders,
96105
stake_table: members,
97106
da_stake_table: da_members,
107+
randomized_committee,
98108
indexed_stake_table,
99109
indexed_da_stake_table,
100110
}
@@ -205,13 +215,7 @@ impl<TYPES: NodeType> Membership<TYPES> for RandomizedCommittee<TYPES> {
205215
view_number: <TYPES as NodeType>::View,
206216
_epoch: Option<<TYPES as NodeType>::Epoch>,
207217
) -> Result<TYPES::SignatureKey> {
208-
let mut rng: StdRng = rand::SeedableRng::seed_from_u64(*view_number);
209-
210-
let randomized_view_number: u64 = rng.gen_range(0..=u64::MAX);
211-
#[allow(clippy::cast_possible_truncation)]
212-
let index = randomized_view_number as usize % self.eligible_leaders.len();
213-
214-
let res = self.eligible_leaders[index].clone();
218+
let res = select_randomized_leader(&self.randomized_committee, *view_number);
215219

216220
Ok(TYPES::SignatureKey::public_key(&res))
217221
}
@@ -248,5 +252,5 @@ impl<TYPES: NodeType> Membership<TYPES> for RandomizedCommittee<TYPES> {
248252
.unwrap()
249253
}
250254

251-
fn add_drb_result(&mut self, _epoch: <TYPES as NodeType>::Epoch, _drb_result: DrbResult) {}
255+
fn add_drb_result(&mut self, _epoch: <TYPES as NodeType>::Epoch, _drb: DrbResult) {}
252256
}

types/src/v0/impls/stake_table.rs

+48-25
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
use super::{
2-
v0_3::{DAMembers, StakeTable, StakeTables},
3-
Header, L1Client, NodeState, PubKey, SeqTypes,
1+
use std::{
2+
cmp::max,
3+
collections::{BTreeMap, BTreeSet, HashMap},
4+
num::NonZeroU64,
5+
str::FromStr,
46
};
57

68
use async_trait::async_trait;
@@ -11,7 +13,10 @@ use hotshot::types::{BLSPubKey, SignatureKey as _};
1113
use hotshot_contract_adapter::stake_table::{bls_alloy_to_jf, NodeInfoJf};
1214
use hotshot_types::{
1315
data::EpochNumber,
14-
drb::DrbResult,
16+
drb::{
17+
election::{generate_stake_cdf, select_randomized_leader, RandomizedCommittee},
18+
DrbResult,
19+
},
1520
stake_table::StakeTableEntry,
1621
traits::{
1722
election::Membership,
@@ -21,16 +26,14 @@ use hotshot_types::{
2126
PeerConfig,
2227
};
2328
use itertools::Itertools;
24-
use std::{
25-
cmp::max,
26-
collections::{BTreeMap, BTreeSet, HashMap},
27-
num::NonZeroU64,
28-
str::FromStr,
29-
};
3029
use thiserror::Error;
31-
3230
use url::Url;
3331

32+
use super::{
33+
v0_3::{DAMembers, StakeTable, StakeTables},
34+
Header, L1Client, NodeState, PubKey, SeqTypes,
35+
};
36+
3437
type Epoch = <SeqTypes as NodeType>::Epoch;
3538

3639
impl StakeTables {
@@ -107,8 +110,8 @@ pub struct EpochCommittees {
107110
/// Address of Stake Table Contract
108111
contract_address: Option<Address>,
109112

110-
/// The results of DRB calculations
111-
drb_result_table: BTreeMap<Epoch, DrbResult>,
113+
/// Randomized committees, filled when we receive the DrbResult
114+
randomized_committees: BTreeMap<Epoch, RandomizedCommittee<StakeTableEntry<PubKey>>>,
112115
}
113116

114117
#[derive(Debug, Clone, PartialEq)]
@@ -256,7 +259,7 @@ impl EpochCommittees {
256259
_epoch_size: epoch_size,
257260
l1_client: instance_state.l1_client.clone(),
258261
contract_address: instance_state.chain_config.stake_table_contract,
259-
drb_result_table: BTreeMap::new(),
262+
randomized_committees: BTreeMap::new(),
260263
}
261264
}
262265

@@ -337,7 +340,7 @@ impl Membership<SeqTypes> for EpochCommittees {
337340
l1_client: L1Client::new(vec![Url::from_str("http:://ab.b").unwrap()])
338341
.expect("Failed to create L1 client"),
339342
contract_address: None,
340-
drb_result_table: BTreeMap::new(),
343+
randomized_committees: BTreeMap::new(),
341344
}
342345
}
343346

@@ -432,15 +435,26 @@ impl Membership<SeqTypes> for EpochCommittees {
432435
view_number: <SeqTypes as NodeType>::View,
433436
epoch: Option<Epoch>,
434437
) -> Result<PubKey, Self::Error> {
435-
let leaders = self
436-
.state(&epoch)
437-
.ok_or(LeaderLookupError)?
438-
.eligible_leaders
439-
.clone();
438+
if let Some(epoch) = epoch {
439+
let Some(randomized_committee) = self.randomized_committees.get(&epoch) else {
440+
tracing::error!(
441+
"We are missing the randomized committee for epoch {}",
442+
epoch
443+
);
444+
return Err(LeaderLookupError);
445+
};
446+
447+
Ok(PubKey::public_key(&select_randomized_leader(
448+
randomized_committee,
449+
*view_number,
450+
)))
451+
} else {
452+
let leaders = &self.non_epoch_committee.eligible_leaders;
440453

441-
let index = *view_number as usize % leaders.len();
442-
let res = leaders[index].clone();
443-
Ok(PubKey::public_key(&res))
454+
let index = *view_number as usize % leaders.len();
455+
let res = leaders[index].clone();
456+
Ok(PubKey::public_key(&res))
457+
}
444458
}
445459

446460
/// Get the total number of nodes in the committee
@@ -504,8 +518,17 @@ impl Membership<SeqTypes> for EpochCommittees {
504518
})
505519
}
506520

507-
fn add_drb_result(&mut self, epoch: Epoch, drb_result: DrbResult) {
508-
self.drb_result_table.insert(epoch, drb_result);
521+
fn add_drb_result(&mut self, epoch: Epoch, drb: DrbResult) {
522+
let Some(raw_stake_table) = self.state.get(&epoch) else {
523+
tracing::error!("add_drb_result({}, {:?}) was called, but we do not yet have the stake table for epoch {}", epoch, drb, epoch);
524+
return;
525+
};
526+
527+
let randomized_committee =
528+
generate_stake_cdf(raw_stake_table.eligible_leaders.clone(), drb);
529+
530+
self.randomized_committees
531+
.insert(epoch, randomized_committee);
509532
}
510533
}
511534

0 commit comments

Comments
 (0)