From 3916f603ae835757fb1657ccd161f694dbd22454 Mon Sep 17 00:00:00 2001 From: Ed Hastings Date: Thu, 24 Jul 2025 15:03:56 -0700 Subject: [PATCH 1/8] some experimental adjustments for faster chain --- node/src/components/block_accumulator.rs | 3 ++- .../components/consensus/era_supervisor.rs | 25 +++++++++++++++++++ node/src/components/network/outgoing.rs | 2 +- node/src/components/transaction_buffer.rs | 10 ++++---- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/node/src/components/block_accumulator.rs b/node/src/components/block_accumulator.rs index e655f6ec97..6194280b04 100644 --- a/node/src/components/block_accumulator.rs +++ b/node/src/components/block_accumulator.rs @@ -235,7 +235,8 @@ impl BlockAccumulator { let block_timestamps = self.peer_block_timestamps.entry(sender).or_default(); // Prune the timestamps, so the count reflects only the most recently added acceptors. - let purge_interval = self.purge_interval; + // assume at least a 1 milli purge interval to avoid 0 purge interval mathing issues + let purge_interval = self.purge_interval.max(TimeDiff::from_millis(1)); while block_timestamps .front() .is_some_and(|(_, timestamp)| timestamp.elapsed() > purge_interval) diff --git a/node/src/components/consensus/era_supervisor.rs b/node/src/components/consensus/era_supervisor.rs index 7883c9f5ab..02a14b5bb2 100644 --- a/node/src/components/consensus/era_supervisor.rs +++ b/node/src/components/consensus/era_supervisor.rs @@ -112,6 +112,8 @@ pub struct EraSupervisor { next_block_height: u64, /// The height of the next block to be executed. If this falls too far behind, we pause. next_executed_height: u64, + /// The last seen added block time. + last_block_time: Option, #[data_size(skip)] metrics: Metrics, /// The path to the folder where unit files will be stored. @@ -151,6 +153,7 @@ impl EraSupervisor { chainspec, config, next_block_height: 0, + last_block_time: None, metrics, unit_files_folder, next_executed_height: 0, @@ -801,12 +804,33 @@ impl EraSupervisor { block_payload, block_context, } = new_block_payload; + match self.current_era() { None => { warn!("new block payload but no initialized era"); Effects::new() } Some(current_era) => { + // if proposal is empty, do not send it unless too many increments of block time + // have passed. this turns down the volume of empty blocks + if block_payload.count(None) == 0 { + if let Some(last_block_time) = self.last_block_time { + let increment = self + .chainspec + .core_config + .minimum_block_time + .saturating_mul(10); + let tolerance = last_block_time.saturating_add(increment); + if tolerance <= Timestamp::now() { + debug!( + era = era_id.value(), + %tolerance, + "empty block payload within tolerance for skipping an empty proposal"); + return Effects::new(); + } + } + } + if era_id.saturating_add(PAST_EVIDENCE_ERAS) < current_era || !self.open_eras.contains_key(&era_id) { @@ -827,6 +851,7 @@ impl EraSupervisor { rng: &mut NodeRng, block_header: BlockHeader, ) -> Effects { + self.last_block_time = Some(block_header.timestamp()); self.last_progress = Timestamp::now(); self.next_executed_height = self .next_executed_height diff --git a/node/src/components/network/outgoing.rs b/node/src/components/network/outgoing.rs index e90fc8a0ce..ee702031b7 100644 --- a/node/src/components/network/outgoing.rs +++ b/node/src/components/network/outgoing.rs @@ -989,7 +989,7 @@ where | OutgoingState::Connecting { .. } => { // We should, under normal circumstances, not receive drop notifications for // any of these. Connection failures are handled by the dialer. - warn!("unexpected drop notification"); + warn!(%outgoing.state, "unexpected drop notification"); None } OutgoingState::Connected { .. } => { diff --git a/node/src/components/transaction_buffer.rs b/node/src/components/transaction_buffer.rs index f1f066d000..fcc5024431 100644 --- a/node/src/components/transaction_buffer.rs +++ b/node/src/components/transaction_buffer.rs @@ -283,7 +283,7 @@ impl TransactionBuffer { .insert(transaction_hash, (expiry_time, Some(footprint))) { Some(prev) => { - warn!(%transaction_hash, ?prev, "TransactionBuffer: transaction upserted"); + debug!(%transaction_hash, ?prev, "TransactionBuffer: transaction upserted"); } None => { debug!(%transaction_hash, "TransactionBuffer: new transaction buffered"); @@ -667,6 +667,10 @@ where { type Event = Event; + fn name(&self) -> &str { + COMPONENT_NAME + } + fn handle_event( &mut self, effect_builder: EffectBuilder, @@ -806,8 +810,4 @@ where }, } } - - fn name(&self) -> &str { - COMPONENT_NAME - } } From 716986d0f0c0a94b8799a9d4e21c4a53a2d97422 Mon Sep 17 00:00:00 2001 From: Ed Hastings Date: Thu, 24 Jul 2025 15:07:00 -0700 Subject: [PATCH 2/8] chainspec and config adjustments --- resources/production/chainspec.toml | 18 +++++++++--------- resources/production/config-example.toml | 6 +++--- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/resources/production/chainspec.toml b/resources/production/chainspec.toml index 79a1c94b92..0d02142693 100644 --- a/resources/production/chainspec.toml +++ b/resources/production/chainspec.toml @@ -27,9 +27,9 @@ maximum_net_message_size = 25_165_824 era_duration = '120 minutes' # Minimum number of blocks per era. An era will take longer than `era_duration` if that is necessary to reach the # minimum height. -minimum_era_height = 20 +minimum_era_height = 320 # Minimum difference between a block's and its child's timestamp. -minimum_block_time = '16384 ms' +minimum_block_time = '1000 ms' # Number of slots available in validator auction. validator_slots = 100 # A number between 0 and 1 representing the fault tolerance threshold as a fraction, used by the internal finalizer. @@ -90,7 +90,7 @@ finders_fee = [1, 5] # The proportion of baseline rewards going to reward finality signatures specifically. finality_signature_proportion = [95, 100] # Lookback interval indicating which past block we are looking at to reward. -signature_rewards_max_delay = 3 +signature_rewards_max_delay = 6 # Allows transfers between accounts in the blockchain network. # # Setting this to false restricts normal accounts from sending tokens to other accounts, allowing transfers only to administrators. @@ -188,9 +188,9 @@ max_ttl = '2 hours' # The maximum number of approvals permitted in a single block. block_max_approval_count = 2600 # Maximum block size in bytes including transactions contained by the block. 0 means unlimited. -max_block_size = 5_242_880 +max_block_size = 873_813 # The upper limit of total gas of all transactions in a block. -block_gas_limit = 1_625_000_000_000 +block_gas_limit = 270_833_333_333 # The minimum amount in motes for a valid native transfer. native_transfer_minimum_motes = 2_500_000_000 # The maximum value to which `transaction_acceptor.timestamp_leeway` can be set in the config.toml file. @@ -216,13 +216,13 @@ vm_casper_v2 = false # [2] -> Max args length size in bytes for a given transaction in a certain lane # [3] -> Transaction gas limit for a given transaction in a certain lane # [4] -> The maximum number of transactions the lane can contain -native_mint_lane = [0, 2048, 1024, 100_000_000, 650] -native_auction_lane = [1, 3096, 2048, 2_500_000_000, 650] +native_mint_lane = [0, 2048, 1024, 100_000_000, 100] +native_auction_lane = [1, 3096, 2048, 2_500_000_000, 100] install_upgrade_lane = [2, 750_000, 2048, 1_000_000_000_000, 1] wasm_lanes = [ [3, 750_000, 2048, 1_000_000_000_000, 1], - [4, 131_072, 1024, 100_000_000_000, 2], - [5, 65_536, 512, 5_000_000_000, 80] + [4, 131_072, 1024, 100_000_000_000, 1], + [5, 65_536, 512, 5_000_000_000, 15] ] [transactions.deploy] diff --git a/resources/production/config-example.toml b/resources/production/config-example.toml index de4dfc8a83..aaae5146fe 100644 --- a/resources/production/config-example.toml +++ b/resources/production/config-example.toml @@ -532,13 +532,13 @@ validate_and_store_timeout = '1 minute' [block_accumulator] # Block height difference threshold for starting to execute the blocks. -attempt_execution_threshold = 3 +attempt_execution_threshold = 20 # Accepted time interval for inactivity in block accumulator. -dead_air_interval = '3 minutes' +dead_air_interval = '30 seconds' # Time after which the block acceptors are considered old and can be purged. -purge_interval = '1 minute' +purge_interval = '30 seconds' # ================================================ From 74d1fa8ffecd6fbc4a01a21a51ee13755160a0fb Mon Sep 17 00:00:00 2001 From: Ed Hastings Date: Fri, 25 Jul 2025 11:44:44 -0700 Subject: [PATCH 3/8] chainspec and logging adjustments --- node/src/components/consensus/era_supervisor.rs | 4 ++-- resources/production/chainspec.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/node/src/components/consensus/era_supervisor.rs b/node/src/components/consensus/era_supervisor.rs index 02a14b5bb2..db77d4bddb 100644 --- a/node/src/components/consensus/era_supervisor.rs +++ b/node/src/components/consensus/era_supervisor.rs @@ -822,10 +822,10 @@ impl EraSupervisor { .saturating_mul(10); let tolerance = last_block_time.saturating_add(increment); if tolerance <= Timestamp::now() { - debug!( + info!( era = era_id.value(), %tolerance, - "empty block payload within tolerance for skipping an empty proposal"); + "SKIPPING EMPTY PROPOSAL: within tolerance for skipping an empty proposal"); return Effects::new(); } } diff --git a/resources/production/chainspec.toml b/resources/production/chainspec.toml index 0d02142693..0e1b0655a3 100644 --- a/resources/production/chainspec.toml +++ b/resources/production/chainspec.toml @@ -24,10 +24,10 @@ maximum_net_message_size = 25_165_824 [core] # Era duration. -era_duration = '120 minutes' +era_duration = '180 minutes' # Minimum number of blocks per era. An era will take longer than `era_duration` if that is necessary to reach the # minimum height. -minimum_era_height = 320 +minimum_era_height = 500 # Minimum difference between a block's and its child's timestamp. minimum_block_time = '1000 ms' # Number of slots available in validator auction. From d69fa2d762903846c6927543d8b65aca59285efb Mon Sep 17 00:00:00 2001 From: Ed Hastings Date: Mon, 28 Jul 2025 15:47:39 -0700 Subject: [PATCH 4/8] test fixup --- node/src/reactor/main_reactor/tests/network_general.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node/src/reactor/main_reactor/tests/network_general.rs b/node/src/reactor/main_reactor/tests/network_general.rs index 5d3e2fc5a8..640cda3f03 100644 --- a/node/src/reactor/main_reactor/tests/network_general.rs +++ b/node/src/reactor/main_reactor/tests/network_general.rs @@ -456,7 +456,7 @@ async fn network_should_recover_from_stall() { } // Ensure all nodes progress until block 3 is marked complete. - fixture.run_until_block_height(3, TEN_SECS).await; + fixture.run_until_block_height(3, ONE_MIN).await; } #[tokio::test] From 049996286050e1d5bd5aa91e982b37e8dc93ca86 Mon Sep 17 00:00:00 2001 From: Ed Hastings Date: Tue, 29 Jul 2025 15:39:31 -0700 Subject: [PATCH 5/8] configurable empty_proposal_tolerance_interval --- node/src/components/consensus/config.rs | 3 + .../components/consensus/era_supervisor.rs | 57 ++++++---- node/src/components/consensus/metrics.rs | 105 ++++++++++++++++-- resources/local/config.toml | 14 +++ resources/production/config-example.toml | 15 +++ 5 files changed, 162 insertions(+), 32 deletions(-) diff --git a/node/src/components/consensus/config.rs b/node/src/components/consensus/config.rs index b828431f64..795226a1dc 100644 --- a/node/src/components/consensus/config.rs +++ b/node/src/components/consensus/config.rs @@ -26,6 +26,8 @@ pub struct Config { /// The maximum number of blocks by which execution is allowed to lag behind finalization. /// If it is more than that, consensus will pause, and resume once the executor has caught up. pub max_execution_delay: u64, + /// The maximum time in millis to skip proposing an empty block. + pub empty_proposal_tolerance_interval: u64, /// Highway-specific node configuration. #[serde(default)] pub highway: HighwayConfig, @@ -39,6 +41,7 @@ impl Default for Config { Config { secret_key_path: External::Missing, max_execution_delay: DEFAULT_MAX_EXECUTION_DELAY, + empty_proposal_tolerance_interval: u64::default(), highway: HighwayConfig::default(), zug: ZugConfig::default(), } diff --git a/node/src/components/consensus/era_supervisor.rs b/node/src/components/consensus/era_supervisor.rs index db77d4bddb..0a266b3a4d 100644 --- a/node/src/components/consensus/era_supervisor.rs +++ b/node/src/components/consensus/era_supervisor.rs @@ -10,6 +10,13 @@ pub(super) mod debug; mod era; +use anyhow::Error; +use datasize::DataSize; +use futures::{Future, FutureExt}; +use itertools::Itertools; +use prometheus::Registry; +use rand::Rng; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; use std::{ cmp, collections::{BTreeMap, BTreeSet, HashMap}, @@ -20,22 +27,14 @@ use std::{ sync::Arc, time::Duration, }; - -use anyhow::Error; -use datasize::DataSize; -use futures::{Future, FutureExt}; -use itertools::Itertools; -use prometheus::Registry; -use rand::Rng; -use serde::{de::DeserializeOwned, Deserialize, Serialize}; use tracing::{debug, error, info, trace, warn}; use casper_binary_port::{ConsensusStatus, ConsensusValidatorChanges}; use casper_types::{ Approval, AsymmetricType, BlockHash, BlockHeader, Chainspec, ConsensusProtocolName, Digest, - DisplayIter, EraId, PublicKey, RewardedSignatures, Timestamp, Transaction, TransactionHash, - ValidatorChange, + DisplayIter, EraId, PublicKey, RewardedSignatures, TimeDiff, Timestamp, Transaction, + TransactionHash, ValidatorChange, }; use crate::{ @@ -76,6 +75,8 @@ use crate::{components::consensus::error::CreateNewEraError, types::InvalidPropo const FTT_EXCEEDED_SHUTDOWN_DELAY_MILLIS: u64 = 60 * 1000; /// A warning is printed if a timer is delayed by more than this. const TIMER_DELAY_WARNING_MILLIS: u64 = 1000; +/// Maximum empty proposal tolerance multiple. +const MAXIMUM_EMPTY_PROPOSAL_TOLERANCE_MULTIPLE: u64 = 20; /// The number of eras across which evidence can be cited. /// If this is 1, you can cite evidence from the previous era, but not the one before that. @@ -112,6 +113,8 @@ pub struct EraSupervisor { next_block_height: u64, /// The height of the next block to be executed. If this falls too far behind, we pause. next_executed_height: u64, + /// The maximum time in millis to skip proposing an empty block. + empty_proposal_tolerance_interval_to_use: u64, /// The last seen added block time. last_block_time: Option, #[data_size(skip)] @@ -147,6 +150,13 @@ impl EraSupervisor { info!(our_id = %validator_matrix.public_signing_key(), "EraSupervisor pubkey",); let metrics = Metrics::new(registry)?; + let empty_proposal_tolerance_interval_to_use = chainspec + .core_config + .minimum_block_time + .saturating_mul(MAXIMUM_EMPTY_PROPOSAL_TOLERANCE_MULTIPLE) + .millis() + .min(config.empty_proposal_tolerance_interval); + let era_supervisor = Self { open_eras: Default::default(), validator_matrix, @@ -157,6 +167,7 @@ impl EraSupervisor { metrics, unit_files_folder, next_executed_height: 0, + empty_proposal_tolerance_interval_to_use, last_progress: Timestamp::now(), message_delay_failpoint: Failpoint::new("consensus.message_delay"), proposal_delay_failpoint: Failpoint::new("consensus.proposal_delay"), @@ -813,19 +824,23 @@ impl EraSupervisor { Some(current_era) => { // if proposal is empty, do not send it unless too many increments of block time // have passed. this turns down the volume of empty blocks + + let now = Timestamp::now(); if block_payload.count(None) == 0 { + // THIS BEHAVIOR ALLOWS FOR SKIPPING OF EMPTY PROPOSALS if let Some(last_block_time) = self.last_block_time { - let increment = self - .chainspec - .core_config - .minimum_block_time - .saturating_mul(10); - let tolerance = last_block_time.saturating_add(increment); - if tolerance <= Timestamp::now() { + let threshold_to_force_proposal = last_block_time.saturating_add( + TimeDiff::from_millis(self.empty_proposal_tolerance_interval_to_use), + ); + if now < threshold_to_force_proposal { + self.metrics.skipping_empty_proposal(now, last_block_time); info!( - era = era_id.value(), - %tolerance, - "SKIPPING EMPTY PROPOSAL: within tolerance for skipping an empty proposal"); + era = era_id.value(), + ?last_block_time, + ?now, + ?threshold_to_force_proposal, + "SKIPPING EMPTY PROPOSAL: within tolerance for skipping an empty proposal" + ); return Effects::new(); } } @@ -839,7 +854,7 @@ impl EraSupervisor { } let proposed_block = ProposedBlock::new(block_payload, block_context); self.delegate_to_era(effect_builder, rng, era_id, move |consensus, _| { - consensus.propose(proposed_block, Timestamp::now()) + consensus.propose(proposed_block, now) }) } } diff --git a/node/src/components/consensus/metrics.rs b/node/src/components/consensus/metrics.rs index 5e918fb658..02de466e6c 100644 --- a/node/src/components/consensus/metrics.rs +++ b/node/src/components/consensus/metrics.rs @@ -1,12 +1,13 @@ -use prometheus::{Gauge, IntGauge, Registry}; - -use casper_types::Timestamp; - use crate::{types::FinalizedBlock, unregister_metric}; +use casper_types::Timestamp; +use prometheus::{Gauge, Histogram, HistogramOpts, IntGauge, Registry}; +use std::time::Duration; /// Network metrics to track Consensus #[derive(Debug)] pub(super) struct Metrics { + /// The current era. + pub(super) consensus_current_era: IntGauge, /// Gauge to track time between proposal and finalization. finalization_time: Gauge, /// Amount of finalized blocks. @@ -15,8 +16,15 @@ pub(super) struct Metrics { time_of_last_proposed_block: IntGauge, /// Timestamp of the most recently finalized block. time_of_last_finalized_block: IntGauge, - /// The current era. - pub(super) consensus_current_era: IntGauge, + + /// INTENTIONALLY SKIPPED BLOCKS METRICS + /// Histogram of frequency of skipped proposals. + skipped_empty_proposal_hist: Histogram, + /// Count of skipped empty proposal. + skipped_empty_proposal: Gauge, + /// Timestamp of skipped empty proposal. + time_of_last_skipped_empty_proposal: IntGauge, + /// Registry component. registry: Registry, } @@ -29,27 +37,87 @@ impl Metrics { )?; let finalized_block_count = IntGauge::new("amount_of_blocks", "the number of blocks finalized so far")?; - let time_of_last_proposed_block = IntGauge::new( - "time_of_last_block_payload", - "timestamp of the most recently accepted block payload", - )?; let time_of_last_finalized_block = IntGauge::new( "time_of_last_finalized_block", "timestamp of the most recently finalized block", )?; let consensus_current_era = IntGauge::new("consensus_current_era", "the current era in consensus")?; + let time_of_last_proposed_block = IntGauge::new( + "time_of_last_block_payload", + "timestamp of the most recently accepted block payload", + )?; registry.register(Box::new(finalization_time.clone()))?; registry.register(Box::new(finalized_block_count.clone()))?; registry.register(Box::new(consensus_current_era.clone()))?; registry.register(Box::new(time_of_last_proposed_block.clone()))?; registry.register(Box::new(time_of_last_finalized_block.clone()))?; + + // SKIPPED EMPTY PROPOSAL METRICS + // HISTOGRAM, COUNT, and MOST RECENT INSTANCE (timestamp since epoch) + + /* + + In Prometheus, when using exponential_buckets to define histogram buckets, the count + variable specifies the number of buckets to generate. + Specifically: + + The exponential_buckets(start, factor, count) function creates count buckets + for a histogram. + + The start parameter defines the upper bound of the lowest bucket. + The factor parameter determines the multiplier for each subsequent bucket's upper bound. + Each following bucket's upper bound is factor times the previous bucket's upper bound. + The count variable directly dictates how many of these exponentially increasing buckets + will be created, excluding the implicit +Inf bucket which is always present for + histograms and handles observations exceeding the highest defined bucket. + + Example: `exponential_buckets(100, 1.2, 3)` + + This would create 3 buckets: Upper bound: 100, Upper bound: 100 * 1.2 = 120, + and Upper bound: 120 * 1.2 = 144. + + Plus the +Inf bucket. + */ + + let skipped_empty_proposal_hist = Histogram::with_opts( + HistogramOpts::new( + "skipped_empty_proposal_hist", + "histogram of skipped proposals start: 1s, factor: 1.75, buckets: 25", + ) + // Create exponential buckets from one second to 1 minute with an off-step factor. + // BUCKETS (set up to accommodate a range of block times from 1s to 32s): + // 1s, 1.75s, 3.06s, 5.35s. 9.37s, 16.41s, 28.72s, 50.26s, 87.96s, 153.93s, + // 269.38s, 471.43s, 825s, 1443.75s, 2526.57s, 4421.51s, 7737.64s, 13540.87s, + // 23696.53s, 41468.93s, 72570.64s, 126998.62s, 222247.59s, 388933.29s, 680633.26s + // A given node's entries should be consistent with their configured + // empty_proposal_tolerance_interval setting and the chainspec minimum_block_time, + // allowing for spillover into the smallest eligible bucket. + // i.e. if the block time is 1s and config'd value is 0s there should be NO entries. + // however if config'd value is 10s, there may be entries in the 1s to 16.41s buckets + // and no entries in the 28.72s and up buckets; there may be entries in the 16.41s + // bucket because it is the smallest bucket skips in the 9.38s to 10s range can fit in. + .buckets(prometheus::exponential_buckets(1.0, 1.75, 25)?), + )?; + let time_of_last_skipped_empty_proposal = IntGauge::new( + "time_of_last_skipped_empty_proposal", + "timestamp of the most recently skipped empty proposal", + )?; + let skipped_empty_proposal = + Gauge::new("skipped_empty_proposal", "count of skipped empty proposals")?; + registry.register(Box::new(skipped_empty_proposal.clone()))?; + registry.register(Box::new(skipped_empty_proposal_hist.clone()))?; + registry.register(Box::new(time_of_last_skipped_empty_proposal.clone()))?; + Ok(Metrics { + consensus_current_era, finalization_time, finalized_block_count, time_of_last_proposed_block, time_of_last_finalized_block, - consensus_current_era, + skipped_empty_proposal_hist, + skipped_empty_proposal, + time_of_last_skipped_empty_proposal, registry: registry.clone(), }) } @@ -69,6 +137,16 @@ impl Metrics { self.time_of_last_proposed_block .set(Timestamp::now().millis() as i64); } + + /// Updates the metrics and records a skipped empty proposal. + pub(super) fn skipping_empty_proposal(&mut self, now: Timestamp, last_block_time: Timestamp) { + let elapsed = + Duration::from_millis(now.saturating_diff(last_block_time).millis()).as_secs_f64(); + self.skipped_empty_proposal_hist.observe(elapsed); + self.skipped_empty_proposal.inc(); + self.time_of_last_skipped_empty_proposal + .set(now.millis() as i64); + } } impl Drop for Metrics { @@ -78,5 +156,10 @@ impl Drop for Metrics { unregister_metric!(self.registry, self.consensus_current_era); unregister_metric!(self.registry, self.time_of_last_finalized_block); unregister_metric!(self.registry, self.time_of_last_proposed_block); + + // SKIPPED EMPTY PROPOSAL METRICS + unregister_metric!(self.registry, self.skipped_empty_proposal_hist); + unregister_metric!(self.registry, self.skipped_empty_proposal); + unregister_metric!(self.registry, self.time_of_last_skipped_empty_proposal); } } diff --git a/resources/local/config.toml b/resources/local/config.toml index 0973eac1d5..898d1ea3b1 100644 --- a/resources/local/config.toml +++ b/resources/local/config.toml @@ -96,6 +96,20 @@ secret_key_path = 'secret_key.pem' # If it is more than that, consensus will pause, and resume once the executor has caught up. max_execution_delay = 3 +# If a validating node is selected to propose a block but does not have any transactions to propose, +# it results in an empty block being proposed. Empty blocks require computation and network traffic +# to process and store. In periods of no to low activity on the network, a sequence of such empty +# blocks are produced on the chain, taking up disk space for no real benefit. +# +# This setting allows a validator to opt to not propose an empty block unless the time since the +# last block exceeds a time threshold. If this setting is 0, a validating node chosen to propose +# with no transactions will always propose. If this setting is greater than 0, such a node will +# only propose an empty block if the elapsed time since the last block in milliseconds is equal +# or greater. In other words, if the value is 5 a validator selected to propose will not propose +# an empty block unless it has been 5 milliseconds or more since the last block. +# +# A configured value greater than 20 x minimum_block_time is capped to 20 x minimum_block_time. +empty_proposal_tolerance_interval = 0 # ======================================= # Configuration options for Zug consensus diff --git a/resources/production/config-example.toml b/resources/production/config-example.toml index aaae5146fe..40cef81f23 100644 --- a/resources/production/config-example.toml +++ b/resources/production/config-example.toml @@ -96,6 +96,21 @@ secret_key_path = '/etc/casper/validator_keys/secret_key.pem' # If it is more than that, consensus will pause, and resume once the executor has caught up. max_execution_delay = 3 +# If a validating node is selected to propose a block but does not have any transactions to propose, +# it results in an empty block being proposed. Empty blocks require computation and network traffic +# to process and store. In periods of no to low activity on the network, a sequence of such empty +# blocks are produced on the chain, taking up disk space for no real benefit. +# +# This setting allows a validator to opt to not propose an empty block unless the time since the +# last block exceeds a time threshold. If this setting is 0, a validating node chosen to propose +# with no transactions will always propose. If this setting is greater than 0, such a node will +# only propose an empty block if the elapsed time since the last block in milliseconds is equal +# or greater. In other words, if the value is 5 a validator selected to propose will not propose +# an empty block unless it has been 5 milliseconds or more since the last block. +# +# A configured value greater than 20 x minimum_block_time is capped to 20 x minimum_block_time. +empty_proposal_tolerance_interval = 0 + # ======================================= # Configuration options for Zug consensus From 6e0ea65fafd4d48bba6a57a4ba0ee70164801483 Mon Sep 17 00:00:00 2001 From: Ed Hastings Date: Wed, 30 Jul 2025 12:17:09 -0700 Subject: [PATCH 6/8] included consideration for cited signatures in empty proposal determination --- .../src/components/consensus/era_supervisor.rs | 15 +++++++++++---- resources/local/config.toml | 2 +- resources/production/config-example.toml | 3 +-- types/src/block/rewarded_signatures.rs | 18 +++++++++++++++++- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/node/src/components/consensus/era_supervisor.rs b/node/src/components/consensus/era_supervisor.rs index 0a266b3a4d..b7280ebd8d 100644 --- a/node/src/components/consensus/era_supervisor.rs +++ b/node/src/components/consensus/era_supervisor.rs @@ -76,7 +76,7 @@ const FTT_EXCEEDED_SHUTDOWN_DELAY_MILLIS: u64 = 60 * 1000; /// A warning is printed if a timer is delayed by more than this. const TIMER_DELAY_WARNING_MILLIS: u64 = 1000; /// Maximum empty proposal tolerance multiple. -const MAXIMUM_EMPTY_PROPOSAL_TOLERANCE_MULTIPLE: u64 = 20; +const MAXIMUM_EMPTY_PROPOSAL_TOLERANCE_MULTIPLE: u64 = 10; /// The number of eras across which evidence can be cited. /// If this is 1, you can cite evidence from the previous era, but not the one before that. @@ -822,11 +822,18 @@ impl EraSupervisor { Effects::new() } Some(current_era) => { + let now = Timestamp::now(); + // if proposal is empty, do not send it unless too many increments of block time // have passed. this turns down the volume of empty blocks - - let now = Timestamp::now(); - if block_payload.count(None) == 0 { + let is_empty_proposal = { + let lacks_transactions = block_payload.count(None) == 0; + // validator will always have their own signature for the previous block + let lacks_signatures = + block_payload.rewarded_signatures().total_signed_count() <= 1; + lacks_transactions && lacks_signatures + }; + if is_empty_proposal { // THIS BEHAVIOR ALLOWS FOR SKIPPING OF EMPTY PROPOSALS if let Some(last_block_time) = self.last_block_time { let threshold_to_force_proposal = last_block_time.saturating_add( diff --git a/resources/local/config.toml b/resources/local/config.toml index 898d1ea3b1..7a0967d3ea 100644 --- a/resources/local/config.toml +++ b/resources/local/config.toml @@ -108,7 +108,7 @@ max_execution_delay = 3 # or greater. In other words, if the value is 5 a validator selected to propose will not propose # an empty block unless it has been 5 milliseconds or more since the last block. # -# A configured value greater than 20 x minimum_block_time is capped to 20 x minimum_block_time. +# A configured value greater than 10 x minimum_block_time is capped to 10 x minimum_block_time. empty_proposal_tolerance_interval = 0 # ======================================= diff --git a/resources/production/config-example.toml b/resources/production/config-example.toml index 40cef81f23..d37df708ce 100644 --- a/resources/production/config-example.toml +++ b/resources/production/config-example.toml @@ -108,10 +108,9 @@ max_execution_delay = 3 # or greater. In other words, if the value is 5 a validator selected to propose will not propose # an empty block unless it has been 5 milliseconds or more since the last block. # -# A configured value greater than 20 x minimum_block_time is capped to 20 x minimum_block_time. +# A configured value greater than 10 x minimum_block_time is capped to 10 x minimum_block_time. empty_proposal_tolerance_interval = 0 - # ======================================= # Configuration options for Zug consensus # ======================================= diff --git a/types/src/block/rewarded_signatures.rs b/types/src/block/rewarded_signatures.rs index e483f95a38..658635be8d 100644 --- a/types/src/block/rewarded_signatures.rs +++ b/types/src/block/rewarded_signatures.rs @@ -22,6 +22,17 @@ use tracing::error; #[cfg_attr(feature = "json-schema", derive(JsonSchema))] pub struct RewardedSignatures(Vec); +impl RewardedSignatures { + /// Total count of all signed entries. + pub fn total_signed_count(&self) -> u32 { + let mut count = 0; + for entry in &self.0 { + count += entry.signed_count(); + } + count + } +} + /// List of identifiers for finality signatures for a particular past block. /// /// That past block height is current_height - signature_rewards_max_delay, the latter being defined @@ -62,6 +73,11 @@ impl SingleBlockRewardedSignatures { result } + /// Count of signatures. + pub fn signed_count(&self) -> u32 { + self.0.iter().map(|c| c.count_ones()).sum() + } + /// Gets the list of validators which signed from a set of recorded finality signaures (`self`) /// + the era's validators. pub fn to_validator_set( @@ -101,7 +117,7 @@ impl SingleBlockRewardedSignatures { } /// Unpacks the bytes to bits, - /// to get a human readable representation of `PastFinalitySignature`. + /// to get a human-readable representation of `PastFinalitySignature`. #[doc(hidden)] pub fn unpack(&self) -> impl Iterator + '_ { // Returns the bit at the given position (0 or 1): From 373a02296f38aa3483973477a01f2be3cd2fe1a5 Mon Sep 17 00:00:00 2001 From: Ed Hastings Date: Wed, 30 Jul 2025 12:28:25 -0700 Subject: [PATCH 7/8] optimized signature count detection --- node/src/components/consensus/era_supervisor.rs | 3 +-- types/src/block/rewarded_signatures.rs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/node/src/components/consensus/era_supervisor.rs b/node/src/components/consensus/era_supervisor.rs index b7280ebd8d..54512672ce 100644 --- a/node/src/components/consensus/era_supervisor.rs +++ b/node/src/components/consensus/era_supervisor.rs @@ -829,8 +829,7 @@ impl EraSupervisor { let is_empty_proposal = { let lacks_transactions = block_payload.count(None) == 0; // validator will always have their own signature for the previous block - let lacks_signatures = - block_payload.rewarded_signatures().total_signed_count() <= 1; + let lacks_signatures = block_payload.rewarded_signatures().has_at_least(2); lacks_transactions && lacks_signatures }; if is_empty_proposal { diff --git a/types/src/block/rewarded_signatures.rs b/types/src/block/rewarded_signatures.rs index 658635be8d..f2934a618a 100644 --- a/types/src/block/rewarded_signatures.rs +++ b/types/src/block/rewarded_signatures.rs @@ -31,6 +31,19 @@ impl RewardedSignatures { } count } + + /// Returns true when signatures count equals or exceeds `target_count`, else false. + pub fn has_at_least(&self, target_count: u32) -> bool { + let mut count = 0; + for entry in &self.0 { + count += entry.signed_count(); + if count >= target_count { + // short circuit once criteria met + return true; + } + } + false + } } /// List of identifiers for finality signatures for a particular past block. From eff41dd49289ec30df4c5886d4f65a17c8d11651 Mon Sep 17 00:00:00 2001 From: Ed Hastings Date: Wed, 30 Jul 2025 12:38:13 -0700 Subject: [PATCH 8/8] anti-derping inverted bool --- node/src/components/consensus/era_supervisor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/node/src/components/consensus/era_supervisor.rs b/node/src/components/consensus/era_supervisor.rs index 54512672ce..d7902c1ec9 100644 --- a/node/src/components/consensus/era_supervisor.rs +++ b/node/src/components/consensus/era_supervisor.rs @@ -829,7 +829,7 @@ impl EraSupervisor { let is_empty_proposal = { let lacks_transactions = block_payload.count(None) == 0; // validator will always have their own signature for the previous block - let lacks_signatures = block_payload.rewarded_signatures().has_at_least(2); + let lacks_signatures = !block_payload.rewarded_signatures().has_at_least(2); lacks_transactions && lacks_signatures }; if is_empty_proposal {