Implement weak subjectivity safety checks (#7347)

Closes #7273


  https://github.com/ethereum/consensus-specs/pull/4179


Co-Authored-By: Eitan Seri-Levi <eserilev@ucsc.edu>

Co-Authored-By: Eitan Seri- Levi <eserilev@gmail.com>

Co-Authored-By: Michael Sproul <michaelsproul@users.noreply.github.com>

Co-Authored-By: dapplion <35266934+dapplion@users.noreply.github.com>
This commit is contained in:
Eitan Seri-Levi
2026-02-10 13:52:52 -08:00
committed by GitHub
parent a1176e77be
commit 56eb81a5e0
12 changed files with 222 additions and 15 deletions

View File

@@ -41,7 +41,7 @@ use std::sync::Arc;
use std::time::Duration;
use store::{Error as StoreError, HotColdDB, ItemStore, KeyValueStoreOp};
use task_executor::{ShutdownReason, TaskExecutor};
use tracing::{debug, error, info};
use tracing::{debug, error, info, warn};
use tree_hash::TreeHash;
use types::data::CustodyIndex;
use types::{
@@ -848,6 +848,33 @@ where
));
}
// Check if the head snapshot is within the weak subjectivity period
let head_state = &head_snapshot.beacon_state;
let Ok(ws_period) = head_state.compute_weak_subjectivity_period(&self.spec) else {
return Err(format!(
"Unable to compute the weak subjectivity period at the head snapshot slot: {:?}",
head_state.slot()
));
};
if current_slot.epoch(E::slots_per_epoch())
> head_state.slot().epoch(E::slots_per_epoch()) + ws_period
{
if self.chain_config.ignore_ws_check {
warn!(
head_slot=%head_state.slot(),
%current_slot,
"The current head state is outside the weak subjectivity period. You are currently running a node that is susceptible to long range attacks. \
It is highly recommended to purge your db and checkpoint sync. For more information please \
read this blog post: https://blog.ethereum.org/2014/11/25/proof-stake-learned-love-weak-subjectivity"
)
}
return Err(
"The current head state is outside the weak subjectivity period. A node in this state is susceptible to long range attacks. You should purge your db and \
checkpoint sync. For more information please read this blog post: https://blog.ethereum.org/2014/11/25/proof-stake-learned-love-weak-subjectivity \
If you understand the risks, it is possible to ignore this error with the --ignore-ws-check flag.".to_string()
);
}
let validator_pubkey_cache = self
.validator_pubkey_cache
.map(|mut validator_pubkey_cache| {

View File

@@ -117,6 +117,8 @@ pub struct ChainConfig {
/// On Holesky there is a block which is added to this set by default but which can be removed
/// by using `--invalid-block-roots ""`.
pub invalid_block_roots: HashSet<Hash256>,
/// When set to true, the beacon node can be started even if the head state is outside the weak subjectivity period.
pub ignore_ws_check: bool,
/// Disable the getBlobs optimisation to fetch blobs from the EL mempool.
pub disable_get_blobs: bool,
/// The node's custody type, determining how many data columns to custody and sample.
@@ -160,6 +162,7 @@ impl Default for ChainConfig {
block_publishing_delay: None,
data_column_publishing_delay: None,
invalid_block_roots: HashSet::new(),
ignore_ws_check: false,
disable_get_blobs: false,
node_custody_type: NodeCustodyType::Fullnode,
}

View File

@@ -6,7 +6,7 @@ use beacon_chain::{
INVALID_JUSTIFIED_PAYLOAD_SHUTDOWN_REASON, NotifyExecutionLayer, OverrideForkchoiceUpdate,
StateSkipConfig, WhenSlotSkipped,
canonical_head::{CachedHead, CanonicalHead},
test_utils::{BeaconChainHarness, EphemeralHarnessType},
test_utils::{BeaconChainHarness, EphemeralHarnessType, test_spec},
};
use execution_layer::{
ExecutionLayer, ForkchoiceState, PayloadAttributes,
@@ -42,14 +42,11 @@ struct InvalidPayloadRig {
impl InvalidPayloadRig {
fn new() -> Self {
let spec = E::default_spec();
let spec = test_spec::<E>();
Self::new_with_spec(spec)
}
fn new_with_spec(mut spec: ChainSpec) -> Self {
spec.altair_fork_epoch = Some(Epoch::new(0));
spec.bellatrix_fork_epoch = Some(Epoch::new(0));
fn new_with_spec(spec: ChainSpec) -> Self {
let harness = BeaconChainHarness::builder(MainnetEthSpec)
.spec(spec.into())
.chain_config(ChainConfig {

View File

@@ -117,6 +117,7 @@ fn get_harness_import_all_data_columns(
) -> TestHarness {
// Most tests expect to retain historic states, so we use this as the default.
let chain_config = ChainConfig {
ignore_ws_check: true,
reconstruct_historic_states: true,
..ChainConfig::default()
};

View File

@@ -15,7 +15,8 @@ use state_processing::EpochProcessingError;
use state_processing::{per_slot_processing, per_slot_processing::Error as SlotProcessingError};
use std::sync::LazyLock;
use types::{
BeaconState, BeaconStateError, BlockImportSource, Checkpoint, EthSpec, Hash256, MinimalEthSpec,
BeaconState, BeaconStateError, BlockImportSource, ChainSpec, Checkpoint,
DEFAULT_PRE_ELECTRA_WS_PERIOD, EthSpec, ForkName, Hash256, MainnetEthSpec, MinimalEthSpec,
RelativeEpoch, Slot,
};
@@ -38,6 +39,27 @@ fn get_harness(validator_count: usize) -> BeaconChainHarness<EphemeralHarnessTyp
)
}
fn get_harness_with_spec(
validator_count: usize,
spec: &ChainSpec,
) -> BeaconChainHarness<EphemeralHarnessType<MainnetEthSpec>> {
let chain_config = ChainConfig {
reconstruct_historic_states: true,
..Default::default()
};
let harness = BeaconChainHarness::builder(MainnetEthSpec)
.spec(spec.clone().into())
.chain_config(chain_config)
.keypairs(KEYPAIRS[0..validator_count].to_vec())
.fresh_ephemeral_store()
.mock_execution_layer()
.build();
harness.advance_slot();
harness
}
fn get_harness_with_config(
validator_count: usize,
chain_config: ChainConfig,
@@ -1083,3 +1105,28 @@ async fn pseudo_finalize_with_lagging_split_update() {
let expect_true_migration = false;
pseudo_finalize_test_generic(epochs_per_migration, expect_true_migration).await;
}
#[tokio::test]
async fn test_compute_weak_subjectivity_period() {
type E = MainnetEthSpec;
let expected_ws_period_pre_electra = DEFAULT_PRE_ELECTRA_WS_PERIOD;
let expected_ws_period_post_electra = 256;
// test Base variant
let spec = ForkName::Altair.make_genesis_spec(E::default_spec());
let harness = get_harness_with_spec(VALIDATOR_COUNT, &spec);
let head_state = harness.get_current_state();
let calculated_ws_period = head_state.compute_weak_subjectivity_period(&spec).unwrap();
assert_eq!(calculated_ws_period, expected_ws_period_pre_electra);
// test Electra variant
let spec = ForkName::Electra.make_genesis_spec(E::default_spec());
let harness = get_harness_with_spec(VALIDATOR_COUNT, &spec);
let head_state = harness.get_current_state();
let calculated_ws_period = head_state.compute_weak_subjectivity_period(&spec).unwrap();
assert_eq!(calculated_ws_period, expected_ws_period_post_electra);
}

View File

@@ -1404,6 +1404,16 @@ pub fn cli_app() -> Command {
.help_heading(FLAG_HEADER)
.display_order(0)
)
.arg(
Arg::new("ignore-ws-check")
.long("ignore-ws-check")
.help("Using this flag allows a node to run in a state that may expose it to long-range attacks. \
For more information please read this blog post: https://blog.ethereum.org/2014/11/25/proof-stake-learned-love-weak-subjectivity \
If you understand the risks, you can use this flag to disable the Weak Subjectivity check at startup.")
.action(ArgAction::SetTrue)
.help_heading(FLAG_HEADER)
.display_order(0)
)
.arg(
Arg::new("builder-fallback-skips")
.long("builder-fallback-skips")

View File

@@ -780,6 +780,8 @@ pub fn get_config<E: EthSpec>(
client_config.chain.paranoid_block_proposal = cli_args.get_flag("paranoid-block-proposal");
client_config.chain.ignore_ws_check = cli_args.get_flag("ignore-ws-check");
/*
* Builder fallback configs.
*/

View File

@@ -22,14 +22,9 @@ use types::{ChainSpec, Epoch, EthSpec, ForkName};
pub type ProductionClient<E> =
Client<Witness<SystemTimeSlotClock, E, BeaconNodeBackend<E>, BeaconNodeBackend<E>>>;
/// The beacon node `Client` that will be used in production.
/// The beacon node `Client` that is used in production.
///
/// Generic over some `EthSpec`.
///
/// ## Notes:
///
/// Despite being titled `Production...`, this code is not ready for production. The name
/// demonstrates an intention, not a promise.
pub struct ProductionBeaconNode<E: EthSpec>(ProductionClient<E>);
impl<E: EthSpec> ProductionBeaconNode<E> {

View File

@@ -509,6 +509,12 @@ Flags:
--http-enable-tls
Serves the RESTful HTTP API server over TLS. This feature is currently
experimental.
--ignore-ws-check
Using this flag allows a node to run in a state that may expose it to
long-range attacks. For more information please read this blog post:
https://blog.ethereum.org/2014/11/25/proof-stake-learned-love-weak-subjectivity
If you understand the risks, you can use this flag to disable the Weak
Subjectivity check at startup.
--import-all-attestations
Import and aggregate all attestations, regardless of validator
subscriptions. This will only import attestations from

View File

@@ -55,9 +55,21 @@ use crate::{
};
pub const CACHED_EPOCHS: usize = 3;
// Pre-electra WS calculations are not supported. On mainnet, pre-electra epochs are outside the weak subjectivity
// period. The default pre-electra WS value is set to 256 to allow for `basic-sim``, `fallback-sim`` test case `revert_minority_fork_on_resume`
// to pass. 256 is a small enough number to trigger the WS safety check pre-electra on mainnet.
pub const DEFAULT_PRE_ELECTRA_WS_PERIOD: u64 = 256;
const MAX_RANDOM_BYTE: u64 = (1 << 8) - 1;
const MAX_RANDOM_VALUE: u64 = (1 << 16) - 1;
// `SAFETY_DECAY` is defined as the maximum percentage tolerable loss in the one-third
// safety margin of FFG finality. Thus, any attack exploiting the Weak Subjectivity Period has
// a safety margin of at least `1/3 - SAFETY_DECAY/100`.
// Spec: https://github.com/ethereum/consensus-specs/blob/1937aff86b41b5171a9bc3972515986f1bbbf303/specs/phase0/weak-subjectivity.md?plain=1#L50-L71
const SAFETY_DECAY: u64 = 10;
pub type Validators<E> = List<Validator, <E as EthSpec>::ValidatorRegistryLimit>;
pub type Balances<E> = List<u64, <E as EthSpec>::ValidatorRegistryLimit>;
@@ -3007,6 +3019,26 @@ impl<E: EthSpec> BeaconState<E> {
Ok(())
}
/// Returns the weak subjectivity period for `self`
pub fn compute_weak_subjectivity_period(
&self,
spec: &ChainSpec,
) -> Result<Epoch, BeaconStateError> {
let total_active_balance = self.get_total_active_balance()?;
let fork_name = self.fork_name_unchecked();
if fork_name.electra_enabled() {
let balance_churn_limit = self.get_balance_churn_limit(spec)?;
compute_weak_subjectivity_period_electra(
total_active_balance,
balance_churn_limit,
spec,
)
} else {
Ok(Epoch::new(DEFAULT_PRE_ELECTRA_WS_PERIOD))
}
}
/// Get the payload timeliness committee for the given `slot`.
///
/// Requires the committee cache to be initialized.
@@ -3382,3 +3414,75 @@ impl<'de, E: EthSpec> ContextDeserialize<'de, ForkName> for BeaconState<E> {
))
}
}
/// Spec: https://github.com/ethereum/consensus-specs/blob/1937aff86b41b5171a9bc3972515986f1bbbf303/specs/electra/weak-subjectivity.md?plain=1#L30
pub fn compute_weak_subjectivity_period_electra(
total_active_balance: u64,
balance_churn_limit: u64,
spec: &ChainSpec,
) -> Result<Epoch, BeaconStateError> {
let epochs_for_validator_set_churn = SAFETY_DECAY
.safe_mul(total_active_balance)?
.safe_div(balance_churn_limit.safe_mul(200)?)?;
let ws_period = spec
.min_validator_withdrawability_delay
.safe_add(epochs_for_validator_set_churn)?;
Ok(ws_period)
}
#[cfg(test)]
mod weak_subjectivity_tests {
use crate::state::beacon_state::compute_weak_subjectivity_period_electra;
use crate::{ChainSpec, Epoch, EthSpec, MainnetEthSpec};
const GWEI_PER_ETH: u64 = 1_000_000_000;
#[test]
fn test_compute_weak_subjectivity_period_electra() {
let mut spec = MainnetEthSpec::default_spec();
spec.altair_fork_epoch = Some(Epoch::new(0));
spec.bellatrix_fork_epoch = Some(Epoch::new(0));
spec.capella_fork_epoch = Some(Epoch::new(0));
spec.deneb_fork_epoch = Some(Epoch::new(0));
spec.electra_fork_epoch = Some(Epoch::new(0));
// A table of some expected values:
// https://github.com/ethereum/consensus-specs/blob/1937aff86b41b5171a9bc3972515986f1bbbf303/specs/electra/weak-subjectivity.md?plain=1#L44-L54
// (total_active_balance, expected_ws_period)
let expected_values: Vec<(u64, u64)> = vec![
(1_048_576 * GWEI_PER_ETH, 665),
(2_097_152 * GWEI_PER_ETH, 1_075),
(4_194_304 * GWEI_PER_ETH, 1_894),
(8_388_608 * GWEI_PER_ETH, 3_532),
(16_777_216 * GWEI_PER_ETH, 3_532),
(33_554_432 * GWEI_PER_ETH, 3_532),
// This value cross referenced w/
// beacon_chain/tests/tests.rs:test_compute_weak_subjectivity_period
(1536 * GWEI_PER_ETH, 256),
];
for (total_active_balance, expected_ws_period) in expected_values {
let balance_churn_limit = get_balance_churn_limit(total_active_balance, &spec);
let calculated_ws_period = compute_weak_subjectivity_period_electra(
total_active_balance,
balance_churn_limit,
&spec,
)
.unwrap();
assert_eq!(calculated_ws_period, expected_ws_period);
}
}
// caclulate the balance_churn_limit without dealing with states
// and without initializing the active balance cache
fn get_balance_churn_limit(total_active_balance: u64, spec: &ChainSpec) -> u64 {
let churn = std::cmp::max(
spec.min_per_epoch_churn_limit_electra,
total_active_balance / spec.churn_limit_quotient,
);
churn - (churn % spec.effective_balance_increment)
}
}

View File

@@ -17,7 +17,7 @@ pub use balance::Balance;
pub use beacon_state::{
BeaconState, BeaconStateAltair, BeaconStateBase, BeaconStateBellatrix, BeaconStateCapella,
BeaconStateDeneb, BeaconStateElectra, BeaconStateError, BeaconStateFulu, BeaconStateGloas,
BeaconStateHash, BeaconStateRef, CACHED_EPOCHS,
BeaconStateHash, BeaconStateRef, CACHED_EPOCHS, DEFAULT_PRE_ELECTRA_WS_PERIOD,
};
pub use committee_cache::{
CommitteeCache, compute_committee_index_in_epoch, compute_committee_range_in_epoch,

View File

@@ -295,6 +295,21 @@ fn paranoid_block_proposal_on() {
.with_config(|config| assert!(config.chain.paranoid_block_proposal));
}
#[test]
fn ignore_ws_check_enabled() {
CommandLineTest::new()
.flag("ignore-ws-check", None)
.run_with_zero_port()
.with_config(|config| assert!(config.chain.ignore_ws_check));
}
#[test]
fn ignore_ws_check_default() {
CommandLineTest::new()
.run_with_zero_port()
.with_config(|config| assert!(!config.chain.ignore_ws_check));
}
#[test]
fn reset_payload_statuses_default() {
CommandLineTest::new()