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
11 changes: 11 additions & 0 deletions anchor/cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,17 @@ pub struct Node {
)]
pub strict_mfp: bool,

#[clap(
long,
help = "Restore the pre-SIP-102 2s proposer QBFT round timeout instead of the SIP-102 \
1.5s one. This is a committee-wide protocol parameter, not a local performance \
knob: configure it identically across all operators of every shared committee. \
A rollback only takes effect once at most f operators of a committee still run \
the SIP-102 timeout.",
display_order = 0
)]
pub legacy_proposer_round_timeout: bool,

#[clap(
long,
help = "Enable parallel querying and scoring of attestation data across multiple beacon \
Expand Down
17 changes: 16 additions & 1 deletion anchor/client/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ use network::{DEFAULT_DISC_PORT, DEFAULT_TCP_PORT, ListenAddr, ListenAddress};
use network_utils::unused_port::{
unused_tcp4_port, unused_tcp6_port, unused_udp4_port, unused_udp6_port,
};
use qbft_manager::{LEGACY_PROPOSER_ROUND_TIMEOUT, PROPOSER_ROUND_TIMEOUT};
use sensitive_url::SensitiveUrl;
use ssv_types::OperatorId;
use tower_http::cors::AllowOrigin;
use tracing::{error, warn};
use tracing::{error, info, warn};

/// Proposer delay above which startup requires `--allow-dangerous-proposer-delay`.
///
Expand Down Expand Up @@ -141,6 +142,9 @@ pub struct Config {
/// block, giving builders longer to bid for it. One value per side of the Gloas fork, each
/// disabled at zero.
pub proposer_delays: ProposerDelays,
/// Quick-round timeout of proposer QBFT instances: SIP-102's 1.5s unless the operator
/// restored the legacy 2s with `--legacy-proposer-round-timeout`.
pub proposer_round_timeout: Duration,
/// Controls whether the latency measurement service is enabled
pub disable_latency_measurement_service: bool,
/// Enables the beacon head monitor that reacts to head updates from connected beacon nodes.
Expand Down Expand Up @@ -197,6 +201,7 @@ impl Config {
builder_boost_factor: None,
prefer_builder_proposals: false,
proposer_delays: ProposerDelays::default(),
proposer_round_timeout: PROPOSER_ROUND_TIMEOUT,
gas_limit: 36_000_000,
disable_latency_measurement_service: false,
enable_beacon_head_monitor: true,
Expand Down Expand Up @@ -326,6 +331,16 @@ pub fn from_cli(mut cli_args: Node, global_config: GlobalConfig) -> Result<Confi
)?,
};

if cli_args.legacy_proposer_round_timeout {
// Record the rollback so a cross-operator postmortem can tell which timeout this node ran.
info!(
proposer_round_timeout = ?LEGACY_PROPOSER_ROUND_TIMEOUT,
sip102_proposer_round_timeout = ?PROPOSER_ROUND_TIMEOUT,
"Legacy proposer round timeout enabled, using the pre-SIP-102 timeout"
);
config.proposer_round_timeout = LEGACY_PROPOSER_ROUND_TIMEOUT;
}

// Http API server
config.http_api.enabled = cli_args.http_api_options.http;

Expand Down
1 change: 1 addition & 0 deletions anchor/client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,7 @@ impl Client {
config.builder_boost_factor,
config.prefer_builder_proposals,
config.proposer_delays,
config.proposer_round_timeout,
config.strict_mfp,
is_synced.clone(),
executor.clone(),
Expand Down
3 changes: 3 additions & 0 deletions anchor/qbft_manager/src/instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ impl Uninitialized {
let mut timeout_mode = init.timeout_mode;
if let TimeoutMode::Relative {
current_round_start_time,
..
} = &mut timeout_mode
{
tokio::time::sleep_until(*current_round_start_time).await;
Expand Down Expand Up @@ -316,6 +317,7 @@ pub async fn qbft_instance<D: QbftData<Hash = Hash256>>(
&& initialized.qbft.get_round() > old
&& let TimeoutMode::Relative {
current_round_start_time,
..
} = &mut initialized.timeout_mode
{
debug!(
Expand All @@ -337,6 +339,7 @@ pub async fn qbft_instance<D: QbftData<Hash = Hash256>>(
// Reset timer for new round in Relative mode
if let TimeoutMode::Relative {
current_round_start_time,
..
} = &mut initialized.timeout_mode
{
*current_round_start_time = Instant::now();
Expand Down
9 changes: 7 additions & 2 deletions anchor/qbft_manager/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::{fmt::Debug, future::Future, hash::Hash, num::NonZeroU64, sync::Arc};
use std::{fmt::Debug, future::Future, hash::Hash, num::NonZeroU64, sync::Arc, time::Duration};

use bls::PublicKeyBytes;
use dashmap::DashMap;
Expand Down Expand Up @@ -34,6 +34,7 @@ use tracing::{Instrument, debug_span, error, warn};
use types::{ChainSpec, Epoch, EthSpec, Hash256, Slot};

use crate::instance::qbft_instance;
pub use crate::timeout::{LEGACY_PROPOSER_ROUND_TIMEOUT, PROPOSER_ROUND_TIMEOUT};

mod instance;
mod instrumentation;
Expand All @@ -60,7 +61,11 @@ pub enum TimeoutMode {
SlotTime { round_deadline_origin: Instant },
/// Per-round timeouts. Resets on round changes.
/// Used for: block proposals.
Relative { current_round_start_time: Instant },
Relative {
current_round_start_time: Instant,
/// Duration of each quick round, e.g. [`PROPOSER_ROUND_TIMEOUT`].
round_timeout: Duration,
},
}

// Unique Identifier for a committee and its corresponding QBFT instance
Expand Down
149 changes: 49 additions & 100 deletions anchor/qbft_manager/src/tests/timeout_tests.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::*;
use crate::{LEGACY_PROPOSER_ROUND_TIMEOUT, PROPOSER_ROUND_TIMEOUT};

// very important: set paused to true for deterministic timer
#[tokio::test(start_paused = true)]
Expand Down Expand Up @@ -79,6 +80,50 @@ async fn test_timeout(round_timeout_to_test: usize) {
/// after the sleep_until, not cumulative timeouts from start_time.
#[tokio::test(start_paused = true)]
async fn test_relative_mode_timeout() {
// Start the instance 4 seconds in the future (simulating slot timing).
let total_time = run_from_now_until_timed_out(
|now| TimeoutMode::Relative {
current_round_start_time: now + Duration::from_secs(4),
round_timeout: PROPOSER_ROUND_TIMEOUT,
},
3,
)
.await;

// Relative mode waits until current_round_start_time, then runs 3 single 1.5 second rounds.
// SlotTime would measure cumulative 2 second deadlines from the same instant and time out
// at 4 + 2 + 2 + 2 = 10 seconds instead.
assert_eq!(total_time, Duration::from_millis(8_500));
}

/// Test that the `round_timeout` carried by `TimeoutMode::Relative` is the budget the instance
/// timer actually uses, so the `--legacy-proposer-round-timeout` rollback restores 2 second
/// rounds rather than only changing a config value.
#[tokio::test(start_paused = true)]
async fn test_relative_mode_uses_configured_round_timeout() {
for (round_timeout, expected) in [
(PROPOSER_ROUND_TIMEOUT, Duration::from_secs(3)),
(LEGACY_PROPOSER_ROUND_TIMEOUT, Duration::from_secs(4)),
] {
let elapsed = run_from_now_until_timed_out(
|now| TimeoutMode::Relative {
current_round_start_time: now,
round_timeout,
},
2,
)
.await;

assert_eq!(elapsed, expected, "round_timeout {round_timeout:?}");
}
}

/// Run a single instance, building its timeout mode from the current instant, and return the
/// elapsed time until it reports `Completed::TimedOut` after `max_rounds` rounds.
async fn run_from_now_until_timed_out(
timeout_mode: impl FnOnce(Instant) -> TimeoutMode,
max_rounds: usize,
) -> Duration {
let (sender_tx, _sender_rx) = unbounded_channel();
let (message_tx, message_rx) = unbounded_channel();
let (result_tx, result_rx) = oneshot::channel();
Expand All @@ -88,9 +133,7 @@ async fn test_relative_mode_timeout() {
Arc::new(message_sender),
));

let slot_start_time = Instant::now();
// Set start_time 4 seconds in the future (simulating slot timing)
let qbft_start_time = slot_start_time + Duration::from_secs(4);
let now = Instant::now();

message_tx
.send(crate::QbftMessage {
Expand All @@ -102,15 +145,13 @@ async fn test_relative_mode_timeout() {
Role::Committee,
&DutyExecutor::Committee(CommitteeId::default()),
),
timeout_mode: TimeoutMode::Relative {
current_round_start_time: qbft_start_time,
},
timeout_mode: timeout_mode(now),
config: qbft::ConfigBuilder::new(
OperatorId(1),
InstanceHeight::from(0),
IndexSet::from([1, 2, 3, 4].map(OperatorId)),
)
.with_max_rounds(3) // Test 3 rounds
.with_max_rounds(max_rounds)
.build()
.unwrap(),
on_completed: result_tx,
Expand All @@ -120,99 +161,7 @@ async fn test_relative_mode_timeout() {
.unwrap();

assert!(matches!(result_rx.await, Ok(Completed::TimedOut)));

let total_time = Instant::now() - slot_start_time;

// For Relative mode:
// - Wait 4 seconds until current_round_start_time
// - Round 1: 2 seconds (single round timeout, not cumulative)
// - Round 2: 2 seconds
// - Round 3: 2 seconds
// Total: 4 + 2 + 2 + 2 = 10 seconds
//
// If it were SlotTime mode, the instance would start immediately, but the cumulative
// deadlines are measured from the same instant:
// - Round 1 ends at round_deadline_origin + 2 = 6 seconds total
// - Round 2 ends at round_deadline_origin + 4 = 8 seconds total
// - Round 3 ends at round_deadline_origin + 6 = 10 seconds total
// Which happens to be the same for this test, but the key difference is
// Relative mode sleeps until the round start and resets it to Instant::now()

let expected = Duration::from_secs(4 + 2 + 2 + 2);
assert_eq!(total_time, expected);
}

/// Test that SlotTime and Relative modes compute their round deadlines differently.
/// SlotTime measures cumulative deadlines from `round_deadline_origin`, while Relative restarts
/// each round to `Instant::now()`.
#[tokio::test(start_paused = true)]
async fn test_relative_vs_slottime_timing_difference() {
// With the origin at `now`, both modes complete at the same instant, but via
// different calculations (cumulative-from-origin vs per-round-from-now)

async fn run_with_mode(use_relative: bool) -> Duration {
let (sender_tx, _sender_rx) = unbounded_channel();
let (message_tx, message_rx) = unbounded_channel();
let (result_tx, result_rx) = oneshot::channel();
let message_sender = MockMessageSender::new(sender_tx, OperatorId(1));
let _handle = tokio::spawn(qbft_instance::<BeaconVote>(
message_rx,
Arc::new(message_sender),
));

let now = Instant::now();

let timeout_mode = if use_relative {
TimeoutMode::Relative {
current_round_start_time: now,
}
} else {
TimeoutMode::SlotTime {
round_deadline_origin: now,
}
};

message_tx
.send(crate::QbftMessage {
kind: QbftMessageKind::Initialize(QbftInitialization {
initial: setup::generate_test_data(0).0,
validator: Box::new(NoDataValidation),
message_id: MessageId::new(
&DomainType::default(),
Role::Committee,
&DutyExecutor::Committee(CommitteeId::default()),
),
timeout_mode,
config: qbft::ConfigBuilder::new(
OperatorId(1),
InstanceHeight::from(0),
IndexSet::from([1, 2, 3, 4].map(OperatorId)),
)
.with_max_rounds(2)
.build()
.unwrap(),
on_completed: result_tx,
}),
drop_on_finish: None,
})
.unwrap();

assert!(matches!(result_rx.await, Ok(Completed::TimedOut)));
Instant::now() - now
}

let slottime_duration = run_with_mode(false).await;
let relative_duration = run_with_mode(true).await;

// Both should complete in 4 seconds (2 rounds * 2 seconds each)
// The difference is in HOW they calculate it:
// - SlotTime: cumulative from the fixed round_deadline_origin
// - Relative: single-round from current_round_start_time (reset each round)
//
// With the origin at now, both arrive at the same deadlines, but the internal
// calculations differ.
assert_eq!(slottime_duration, Duration::from_secs(4));
assert_eq!(relative_duration, Duration::from_secs(4));
Instant::now() - now
}

/// Test that `SlotTime` round deadlines are a pure function of `round_deadline_origin`: an instance
Expand Down
15 changes: 11 additions & 4 deletions anchor/qbft_manager/src/timeout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ const QUICK_TIMEOUT_THRESHOLD: u64 = 8; // Round 8
const QUICK_TIMEOUT: u64 = 2; // 2 Seconds
const SLOW_TIMEOUT: u64 = 120; // 2 Minutes

/// Proposer quick-round timeout from SIP-102: a 2s round started ~1.1-1.5s into a Gloas slot
/// would begin round 2 after the 3s attestation deadline.
pub const PROPOSER_ROUND_TIMEOUT: Duration = Duration::from_millis(1500);
/// Pre-SIP-102 proposer quick-round timeout, restored by `--legacy-proposer-round-timeout`.
pub const LEGACY_PROPOSER_ROUND_TIMEOUT: Duration = Duration::from_secs(2);

/// Calculate when the current round should timeout.
///
/// For `SlotTime` mode: Cumulative timeout from a fixed origin instant.
Expand All @@ -19,7 +25,7 @@ const SLOW_TIMEOUT: u64 = 120; // 2 Minutes
///
/// For `Relative` mode: Single round timeout from the current round's start time.
/// The timer resets when the round advances.
/// Round ends at: current_round_start_time + timeout for this round only.
/// Round ends at: current_round_start_time + round_timeout for rounds up to 8, then 2 minutes.
/// Used for block proposals (matches Go-SSV behavior).
pub fn calculate_round_timeout(round: u64, timeout_mode: TimeoutMode) -> Option<Instant> {
match timeout_mode {
Expand All @@ -28,7 +34,8 @@ pub fn calculate_round_timeout(round: u64, timeout_mode: TimeoutMode) -> Option<
} => round_deadline_origin.checked_add(cumulative_timeout(round)?),
TimeoutMode::Relative {
current_round_start_time,
} => current_round_start_time.checked_add(single_round_timeout(round)),
round_timeout,
} => current_round_start_time.checked_add(single_round_timeout(round, round_timeout)),
}
}

Expand All @@ -47,9 +54,9 @@ fn cumulative_timeout(round: u64) -> Option<Duration> {
}
}

fn single_round_timeout(round: u64) -> Duration {
fn single_round_timeout(round: u64, round_timeout: Duration) -> Duration {
if round <= QUICK_TIMEOUT_THRESHOLD {
Duration::from_secs(QUICK_TIMEOUT)
round_timeout
} else {
Duration::from_secs(SLOW_TIMEOUT)
}
Expand Down
5 changes: 5 additions & 0 deletions anchor/validator_store/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,8 @@ pub struct AnchorValidatorStore<
prefer_builder_proposals: bool,
/// See [`ProposerDelays`] and [`await_proposer_delay`] for the semantics.
proposer_delays: ProposerDelays,
/// Quick-round timeout of proposer QBFT instances.
proposer_round_timeout: Duration,
strict_mfp: bool,
is_synced: watch::Receiver<bool>,
task_executor: TaskExecutor,
Expand Down Expand Up @@ -558,6 +560,7 @@ impl<T: SlotClock, E: EthSpec, C: ConsensusDecider<E> + 'static> AnchorValidator
builder_boost_factor: Option<u64>,
prefer_builder_proposals: bool,
proposer_delays: ProposerDelays,
proposer_round_timeout: Duration,
strict_mfp: bool,
is_synced: watch::Receiver<bool>,
task_executor: TaskExecutor,
Expand All @@ -583,6 +586,7 @@ impl<T: SlotClock, E: EthSpec, C: ConsensusDecider<E> + 'static> AnchorValidator
builder_boost_factor,
prefer_builder_proposals,
proposer_delays,
proposer_round_timeout,
strict_mfp,
is_synced,
task_executor,
Expand Down Expand Up @@ -970,6 +974,7 @@ impl<T: SlotClock, E: EthSpec, C: ConsensusDecider<E> + 'static> AnchorValidator
let timer = metrics::start_timer_vec(&metrics::CONSENSUS_TIMES, &[metrics::BLOCK]);
let timeout_mode = TimeoutMode::Relative {
current_round_start_time: self.get_instant_in_slot(slot, Duration::ZERO)?,
round_timeout: self.proposer_round_timeout,
};

// Define the proposer instance identity for QBFT consensus
Expand Down
Loading
Loading