mirror of
https://github.com/sigp/lighthouse.git
synced 2026-03-20 13:24:44 +00:00
Add broadcast validation routes to Beacon Node HTTP API (#4316)
## Issue Addressed - #4293 - #4264 ## Proposed Changes *Changes largely follow those suggested in the main issue*. - Add new routes to HTTP API - `post_beacon_blocks_v2` - `post_blinded_beacon_blocks_v2` - Add new routes to `BeaconNodeHttpClient` - `post_beacon_blocks_v2` - `post_blinded_beacon_blocks_v2` - Define new Eth2 common types - `BroadcastValidation`, enum representing the level of validation to apply to blocks prior to broadcast - `BroadcastValidationQuery`, the corresponding HTTP query string type for the above type - ~~Define `_checked` variants of both `publish_block` and `publish_blinded_block` that enforce a validation level at a type level~~ - Add interactive tests to the `bn_http_api_tests` test target covering each validation level (to their own test module, `broadcast_validation_tests`) - `beacon/blocks` - `broadcast_validation=gossip` - Invalid (400) - Full Pass (200) - Partial Pass (202) - `broadcast_validation=consensus` - Invalid (400) - Only gossip (400) - Only consensus pass (i.e., equivocates) (200) - Full pass (200) - `broadcast_validation=consensus_and_equivocation` - Invalid (400) - Invalid due to early equivocation (400) - Only gossip (400) - Only consensus (400) - Pass (200) - `beacon/blinded_blocks` - `broadcast_validation=gossip` - Invalid (400) - Full Pass (200) - Partial Pass (202) - `broadcast_validation=consensus` - Invalid (400) - Only gossip (400) - ~~Only consensus pass (i.e., equivocates) (200)~~ - Full pass (200) - `broadcast_validation=consensus_and_equivocation` - Invalid (400) - Invalid due to early equivocation (400) - Only gossip (400) - Only consensus (400) - Pass (200) - Add a new trait, `IntoGossipVerifiedBlock`, which allows type-level guarantees to be made as to gossip validity - Modify the structure of the `ObservedBlockProducers` cache from a `(slot, validator_index)` mapping to a `((slot, validator_index), block_root)` mapping - Modify `ObservedBlockProducers::proposer_has_been_observed` to return a `SeenBlock` rather than a boolean on success - Punish gossip peer (low) for submitting equivocating blocks - Rename `BlockError::SlashablePublish` to `BlockError::SlashableProposal` ## Additional Info This PR contains changes that directly modify how blocks are verified within the client. For more context, consult [comments in-thread](https://github.com/sigp/lighthouse/pull/4316#discussion_r1234724202). Co-authored-by: Michael Sproul <michael@sigmaprime.io>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
//! Provides the `ObservedBlockProducers` struct which allows for rejecting gossip blocks from
|
||||
//! validators that have already produced a block.
|
||||
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::marker::PhantomData;
|
||||
use types::{BeaconBlockRef, Epoch, EthSpec, Slot, Unsigned};
|
||||
use types::{BeaconBlockRef, Epoch, EthSpec, Hash256, Slot, Unsigned};
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Error {
|
||||
@@ -14,6 +15,12 @@ pub enum Error {
|
||||
ValidatorIndexTooHigh(u64),
|
||||
}
|
||||
|
||||
#[derive(Eq, Hash, PartialEq, Debug, Default)]
|
||||
struct ProposalKey {
|
||||
slot: Slot,
|
||||
proposer: u64,
|
||||
}
|
||||
|
||||
/// Maintains a cache of observed `(block.slot, block.proposer)`.
|
||||
///
|
||||
/// The cache supports pruning based upon the finalized epoch. It does not automatically prune, you
|
||||
@@ -27,7 +34,7 @@ pub enum Error {
|
||||
/// known_distinct_shufflings` which is much smaller.
|
||||
pub struct ObservedBlockProducers<E: EthSpec> {
|
||||
finalized_slot: Slot,
|
||||
items: HashMap<Slot, HashSet<u64>>,
|
||||
items: HashMap<ProposalKey, HashSet<Hash256>>,
|
||||
_phantom: PhantomData<E>,
|
||||
}
|
||||
|
||||
@@ -42,6 +49,24 @@ impl<E: EthSpec> Default for ObservedBlockProducers<E> {
|
||||
}
|
||||
}
|
||||
|
||||
pub enum SeenBlock {
|
||||
Duplicate,
|
||||
Slashable,
|
||||
UniqueNonSlashable,
|
||||
}
|
||||
|
||||
impl SeenBlock {
|
||||
pub fn proposer_previously_observed(self) -> bool {
|
||||
match self {
|
||||
Self::Duplicate | Self::Slashable => true,
|
||||
Self::UniqueNonSlashable => false,
|
||||
}
|
||||
}
|
||||
pub fn is_slashable(&self) -> bool {
|
||||
matches!(self, Self::Slashable)
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: EthSpec> ObservedBlockProducers<E> {
|
||||
/// Observe that the `block` was produced by `block.proposer_index` at `block.slot`. This will
|
||||
/// update `self` so future calls to it indicate that this block is known.
|
||||
@@ -52,16 +77,44 @@ impl<E: EthSpec> ObservedBlockProducers<E> {
|
||||
///
|
||||
/// - `block.proposer_index` is greater than `VALIDATOR_REGISTRY_LIMIT`.
|
||||
/// - `block.slot` is equal to or less than the latest pruned `finalized_slot`.
|
||||
pub fn observe_proposer(&mut self, block: BeaconBlockRef<'_, E>) -> Result<bool, Error> {
|
||||
pub fn observe_proposal(
|
||||
&mut self,
|
||||
block_root: Hash256,
|
||||
block: BeaconBlockRef<'_, E>,
|
||||
) -> Result<SeenBlock, Error> {
|
||||
self.sanitize_block(block)?;
|
||||
|
||||
let did_not_exist = self
|
||||
.items
|
||||
.entry(block.slot())
|
||||
.or_insert_with(|| HashSet::with_capacity(E::SlotsPerEpoch::to_usize()))
|
||||
.insert(block.proposer_index());
|
||||
let key = ProposalKey {
|
||||
slot: block.slot(),
|
||||
proposer: block.proposer_index(),
|
||||
};
|
||||
|
||||
Ok(!did_not_exist)
|
||||
let entry = self.items.entry(key);
|
||||
|
||||
let slashable_proposal = match entry {
|
||||
Entry::Occupied(mut occupied_entry) => {
|
||||
let block_roots = occupied_entry.get_mut();
|
||||
let newly_inserted = block_roots.insert(block_root);
|
||||
|
||||
let is_equivocation = block_roots.len() > 1;
|
||||
|
||||
if is_equivocation {
|
||||
SeenBlock::Slashable
|
||||
} else if !newly_inserted {
|
||||
SeenBlock::Duplicate
|
||||
} else {
|
||||
SeenBlock::UniqueNonSlashable
|
||||
}
|
||||
}
|
||||
Entry::Vacant(vacant_entry) => {
|
||||
let block_roots = HashSet::from([block_root]);
|
||||
vacant_entry.insert(block_roots);
|
||||
|
||||
SeenBlock::UniqueNonSlashable
|
||||
}
|
||||
};
|
||||
|
||||
Ok(slashable_proposal)
|
||||
}
|
||||
|
||||
/// Returns `Ok(true)` if the `block` has been observed before, `Ok(false)` if not. Does not
|
||||
@@ -72,15 +125,33 @@ impl<E: EthSpec> ObservedBlockProducers<E> {
|
||||
///
|
||||
/// - `block.proposer_index` is greater than `VALIDATOR_REGISTRY_LIMIT`.
|
||||
/// - `block.slot` is equal to or less than the latest pruned `finalized_slot`.
|
||||
pub fn proposer_has_been_observed(&self, block: BeaconBlockRef<'_, E>) -> Result<bool, Error> {
|
||||
pub fn proposer_has_been_observed(
|
||||
&self,
|
||||
block: BeaconBlockRef<'_, E>,
|
||||
block_root: Hash256,
|
||||
) -> Result<SeenBlock, Error> {
|
||||
self.sanitize_block(block)?;
|
||||
|
||||
let exists = self
|
||||
.items
|
||||
.get(&block.slot())
|
||||
.map_or(false, |set| set.contains(&block.proposer_index()));
|
||||
let key = ProposalKey {
|
||||
slot: block.slot(),
|
||||
proposer: block.proposer_index(),
|
||||
};
|
||||
|
||||
Ok(exists)
|
||||
if let Some(block_roots) = self.items.get(&key) {
|
||||
let block_already_known = block_roots.contains(&block_root);
|
||||
let no_prev_known_blocks =
|
||||
block_roots.difference(&HashSet::from([block_root])).count() == 0;
|
||||
|
||||
if !no_prev_known_blocks {
|
||||
Ok(SeenBlock::Slashable)
|
||||
} else if block_already_known {
|
||||
Ok(SeenBlock::Duplicate)
|
||||
} else {
|
||||
Ok(SeenBlock::UniqueNonSlashable)
|
||||
}
|
||||
} else {
|
||||
Ok(SeenBlock::UniqueNonSlashable)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `Ok(())` if the given `block` is sane.
|
||||
@@ -112,15 +183,15 @@ impl<E: EthSpec> ObservedBlockProducers<E> {
|
||||
}
|
||||
|
||||
self.finalized_slot = finalized_slot;
|
||||
self.items.retain(|slot, _set| *slot > finalized_slot);
|
||||
self.items.retain(|key, _| key.slot > finalized_slot);
|
||||
}
|
||||
|
||||
/// Returns `true` if the given `validator_index` has been stored in `self` at `epoch`.
|
||||
///
|
||||
/// This is useful for doppelganger detection.
|
||||
pub fn index_seen_at_epoch(&self, validator_index: u64, epoch: Epoch) -> bool {
|
||||
self.items.iter().any(|(slot, producers)| {
|
||||
slot.epoch(E::slots_per_epoch()) == epoch && producers.contains(&validator_index)
|
||||
self.items.iter().any(|(key, _)| {
|
||||
key.slot.epoch(E::slots_per_epoch()) == epoch && key.proposer == validator_index
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -148,9 +219,12 @@ mod tests {
|
||||
|
||||
// Slot 0, proposer 0
|
||||
let block_a = get_block(0, 0);
|
||||
let block_root = block_a.canonical_root();
|
||||
|
||||
assert_eq!(
|
||||
cache.observe_proposer(block_a.to_ref()),
|
||||
cache
|
||||
.observe_proposal(block_root, block_a.to_ref())
|
||||
.map(SeenBlock::proposer_previously_observed),
|
||||
Ok(false),
|
||||
"can observe proposer, indicates proposer unobserved"
|
||||
);
|
||||
@@ -164,7 +238,10 @@ mod tests {
|
||||
assert_eq!(
|
||||
cache
|
||||
.items
|
||||
.get(&Slot::new(0))
|
||||
.get(&ProposalKey {
|
||||
slot: Slot::new(0),
|
||||
proposer: 0
|
||||
})
|
||||
.expect("slot zero should be present")
|
||||
.len(),
|
||||
1,
|
||||
@@ -182,7 +259,10 @@ mod tests {
|
||||
assert_eq!(
|
||||
cache
|
||||
.items
|
||||
.get(&Slot::new(0))
|
||||
.get(&ProposalKey {
|
||||
slot: Slot::new(0),
|
||||
proposer: 0
|
||||
})
|
||||
.expect("slot zero should be present")
|
||||
.len(),
|
||||
1,
|
||||
@@ -207,9 +287,12 @@ mod tests {
|
||||
|
||||
// First slot of finalized epoch, proposer 0
|
||||
let block_b = get_block(E::slots_per_epoch(), 0);
|
||||
let block_root_b = block_b.canonical_root();
|
||||
|
||||
assert_eq!(
|
||||
cache.observe_proposer(block_b.to_ref()),
|
||||
cache
|
||||
.observe_proposal(block_root_b, block_b.to_ref())
|
||||
.map(SeenBlock::proposer_previously_observed),
|
||||
Err(Error::FinalizedBlock {
|
||||
slot: E::slots_per_epoch().into(),
|
||||
finalized_slot: E::slots_per_epoch().into(),
|
||||
@@ -229,7 +312,9 @@ mod tests {
|
||||
let block_b = get_block(three_epochs, 0);
|
||||
|
||||
assert_eq!(
|
||||
cache.observe_proposer(block_b.to_ref()),
|
||||
cache
|
||||
.observe_proposal(block_root_b, block_b.to_ref())
|
||||
.map(SeenBlock::proposer_previously_observed),
|
||||
Ok(false),
|
||||
"can insert non-finalized block"
|
||||
);
|
||||
@@ -238,7 +323,10 @@ mod tests {
|
||||
assert_eq!(
|
||||
cache
|
||||
.items
|
||||
.get(&Slot::new(three_epochs))
|
||||
.get(&ProposalKey {
|
||||
slot: Slot::new(three_epochs),
|
||||
proposer: 0
|
||||
})
|
||||
.expect("the three epochs slot should be present")
|
||||
.len(),
|
||||
1,
|
||||
@@ -262,7 +350,10 @@ mod tests {
|
||||
assert_eq!(
|
||||
cache
|
||||
.items
|
||||
.get(&Slot::new(three_epochs))
|
||||
.get(&ProposalKey {
|
||||
slot: Slot::new(three_epochs),
|
||||
proposer: 0
|
||||
})
|
||||
.expect("the three epochs slot should be present")
|
||||
.len(),
|
||||
1,
|
||||
@@ -276,24 +367,33 @@ mod tests {
|
||||
|
||||
// Slot 0, proposer 0
|
||||
let block_a = get_block(0, 0);
|
||||
let block_root_a = block_a.canonical_root();
|
||||
|
||||
assert_eq!(
|
||||
cache.proposer_has_been_observed(block_a.to_ref()),
|
||||
cache
|
||||
.proposer_has_been_observed(block_a.to_ref(), block_a.canonical_root())
|
||||
.map(|x| x.proposer_previously_observed()),
|
||||
Ok(false),
|
||||
"no observation in empty cache"
|
||||
);
|
||||
assert_eq!(
|
||||
cache.observe_proposer(block_a.to_ref()),
|
||||
cache
|
||||
.observe_proposal(block_root_a, block_a.to_ref())
|
||||
.map(SeenBlock::proposer_previously_observed),
|
||||
Ok(false),
|
||||
"can observe proposer, indicates proposer unobserved"
|
||||
);
|
||||
assert_eq!(
|
||||
cache.proposer_has_been_observed(block_a.to_ref()),
|
||||
cache
|
||||
.proposer_has_been_observed(block_a.to_ref(), block_a.canonical_root())
|
||||
.map(|x| x.proposer_previously_observed()),
|
||||
Ok(true),
|
||||
"observed block is indicated as true"
|
||||
);
|
||||
assert_eq!(
|
||||
cache.observe_proposer(block_a.to_ref()),
|
||||
cache
|
||||
.observe_proposal(block_root_a, block_a.to_ref())
|
||||
.map(SeenBlock::proposer_previously_observed),
|
||||
Ok(true),
|
||||
"observing again indicates true"
|
||||
);
|
||||
@@ -303,7 +403,10 @@ mod tests {
|
||||
assert_eq!(
|
||||
cache
|
||||
.items
|
||||
.get(&Slot::new(0))
|
||||
.get(&ProposalKey {
|
||||
slot: Slot::new(0),
|
||||
proposer: 0
|
||||
})
|
||||
.expect("slot zero should be present")
|
||||
.len(),
|
||||
1,
|
||||
@@ -312,24 +415,33 @@ mod tests {
|
||||
|
||||
// Slot 1, proposer 0
|
||||
let block_b = get_block(1, 0);
|
||||
let block_root_b = block_b.canonical_root();
|
||||
|
||||
assert_eq!(
|
||||
cache.proposer_has_been_observed(block_b.to_ref()),
|
||||
cache
|
||||
.proposer_has_been_observed(block_b.to_ref(), block_b.canonical_root())
|
||||
.map(|x| x.proposer_previously_observed()),
|
||||
Ok(false),
|
||||
"no observation for new slot"
|
||||
);
|
||||
assert_eq!(
|
||||
cache.observe_proposer(block_b.to_ref()),
|
||||
cache
|
||||
.observe_proposal(block_root_b, block_b.to_ref())
|
||||
.map(SeenBlock::proposer_previously_observed),
|
||||
Ok(false),
|
||||
"can observe proposer for new slot, indicates proposer unobserved"
|
||||
);
|
||||
assert_eq!(
|
||||
cache.proposer_has_been_observed(block_b.to_ref()),
|
||||
cache
|
||||
.proposer_has_been_observed(block_b.to_ref(), block_b.canonical_root())
|
||||
.map(|x| x.proposer_previously_observed()),
|
||||
Ok(true),
|
||||
"observed block in slot 1 is indicated as true"
|
||||
);
|
||||
assert_eq!(
|
||||
cache.observe_proposer(block_b.to_ref()),
|
||||
cache
|
||||
.observe_proposal(block_root_b, block_b.to_ref())
|
||||
.map(SeenBlock::proposer_previously_observed),
|
||||
Ok(true),
|
||||
"observing slot 1 again indicates true"
|
||||
);
|
||||
@@ -339,7 +451,10 @@ mod tests {
|
||||
assert_eq!(
|
||||
cache
|
||||
.items
|
||||
.get(&Slot::new(0))
|
||||
.get(&ProposalKey {
|
||||
slot: Slot::new(0),
|
||||
proposer: 0
|
||||
})
|
||||
.expect("slot zero should be present")
|
||||
.len(),
|
||||
1,
|
||||
@@ -348,7 +463,10 @@ mod tests {
|
||||
assert_eq!(
|
||||
cache
|
||||
.items
|
||||
.get(&Slot::new(1))
|
||||
.get(&ProposalKey {
|
||||
slot: Slot::new(1),
|
||||
proposer: 0
|
||||
})
|
||||
.expect("slot zero should be present")
|
||||
.len(),
|
||||
1,
|
||||
@@ -357,45 +475,54 @@ mod tests {
|
||||
|
||||
// Slot 0, proposer 1
|
||||
let block_c = get_block(0, 1);
|
||||
let block_root_c = block_c.canonical_root();
|
||||
|
||||
assert_eq!(
|
||||
cache.proposer_has_been_observed(block_c.to_ref()),
|
||||
cache
|
||||
.proposer_has_been_observed(block_c.to_ref(), block_c.canonical_root())
|
||||
.map(|x| x.proposer_previously_observed()),
|
||||
Ok(false),
|
||||
"no observation for new proposer"
|
||||
);
|
||||
assert_eq!(
|
||||
cache.observe_proposer(block_c.to_ref()),
|
||||
cache
|
||||
.observe_proposal(block_root_c, block_c.to_ref())
|
||||
.map(SeenBlock::proposer_previously_observed),
|
||||
Ok(false),
|
||||
"can observe new proposer, indicates proposer unobserved"
|
||||
);
|
||||
assert_eq!(
|
||||
cache.proposer_has_been_observed(block_c.to_ref()),
|
||||
cache
|
||||
.proposer_has_been_observed(block_c.to_ref(), block_c.canonical_root())
|
||||
.map(|x| x.proposer_previously_observed()),
|
||||
Ok(true),
|
||||
"observed new proposer block is indicated as true"
|
||||
);
|
||||
assert_eq!(
|
||||
cache.observe_proposer(block_c.to_ref()),
|
||||
cache
|
||||
.observe_proposal(block_root_c, block_c.to_ref())
|
||||
.map(SeenBlock::proposer_previously_observed),
|
||||
Ok(true),
|
||||
"observing new proposer again indicates true"
|
||||
);
|
||||
|
||||
assert_eq!(cache.finalized_slot, 0, "finalized slot is zero");
|
||||
assert_eq!(cache.items.len(), 2, "two slots should be present");
|
||||
assert_eq!(cache.items.len(), 3, "three slots should be present");
|
||||
assert_eq!(
|
||||
cache
|
||||
.items
|
||||
.get(&Slot::new(0))
|
||||
.expect("slot zero should be present")
|
||||
.len(),
|
||||
.iter()
|
||||
.filter(|(k, _)| k.slot == cache.finalized_slot)
|
||||
.count(),
|
||||
2,
|
||||
"two proposers should be present in slot 0"
|
||||
);
|
||||
assert_eq!(
|
||||
cache
|
||||
.items
|
||||
.get(&Slot::new(1))
|
||||
.expect("slot zero should be present")
|
||||
.len(),
|
||||
.iter()
|
||||
.filter(|(k, _)| k.slot == Slot::new(1))
|
||||
.count(),
|
||||
1,
|
||||
"only one proposer should be present in slot 1"
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user