diff --git a/consensus/core/src/config/params.rs b/consensus/core/src/config/params.rs index c7746c73cb..7e26064648 100644 --- a/consensus/core/src/config/params.rs +++ b/consensus/core/src/config/params.rs @@ -371,7 +371,7 @@ impl Params { self.blockrate.difficulty_sample_rate } - /// Returns the target time per block + /// Returns the target time per block (milliseconds) #[inline] #[must_use] pub fn target_time_per_block(&self) -> u64 { diff --git a/consensus/core/src/constants.rs b/consensus/core/src/constants.rs index 863a444aad..1b5e8a571b 100644 --- a/consensus/core/src/constants.rs +++ b/consensus/core/src/constants.rs @@ -4,7 +4,7 @@ pub const BLOCK_VERSION: u16 = 1; /// The block version activated by the Toccata hardfork. This change denotes the use of /// the new sequencing commit described in KIP-21. -// TOOD(post-toccata): Remove this and change BLOCK_VERSION to 2. +// TODO(post-toccata): Remove this and change BLOCK_VERSION to 2. pub const TOCCATA_BLOCK_VERSION: u16 = 2; /// TX_VERSION is the current latest supported transaction version. diff --git a/protocol/flows/src/flow_context.rs b/protocol/flows/src/flow_context.rs index 1a17f417ad..89f4b1d273 100644 --- a/protocol/flows/src/flow_context.rs +++ b/protocol/flows/src/flow_context.rs @@ -4,15 +4,16 @@ use crate::flowcontext::{ transactions::TransactionsSpread, }; use crate::user_agent_rule::{UserAgentRuleRejectReason, UserAgentRuleSet}; -use crate::{v7, v8, v9}; +use crate::{v7, v8, v10}; use async_trait::async_trait; use futures::future::join_all; use kaspa_addressmanager::AddressManager; use kaspa_connectionmanager::ConnectionManager; use kaspa_consensus_core::api::{BlockValidationFuture, BlockValidationFutures}; use kaspa_consensus_core::block::Block; -use kaspa_consensus_core::config::Config; +use kaspa_consensus_core::config::{Config, params::ForkActivation}; use kaspa_consensus_core::errors::block::RuleError; +use kaspa_consensus_core::network::{NetworkId, NetworkType}; use kaspa_consensus_core::tx::{Transaction, TransactionId}; use kaspa_consensus_notify::{ notification::{Notification, PruningPointUtxoSetOverrideNotification}, @@ -60,7 +61,11 @@ use tokio_stream::{StreamExt, wrappers::UnboundedReceiverStream}; use uuid::Uuid; /// The P2P protocol version. -const PROTOCOL_VERSION: u32 = 9; +const PROTOCOL_VERSION: u32 = 10; + +/// Testnet 12 was launched with the Toccata flow set under protocol version 9. +const TN12_LAUNCH_PROTOCOL_VERSION: u32 = 9; +const TN12_NETWORK: NetworkId = NetworkId::with_suffix(NetworkType::Testnet, 12); /// See `check_orphan_resolution_range` const BASELINE_ORPHAN_RESOLUTION_RANGE: u32 = 5; @@ -712,9 +717,15 @@ impl ConnectionInitializer for FlowContext { let local_address = self.address_manager.lock().best_local_address(); + // Networks with a scheduled Toccata activation advertise protocol 10. Other networks + // still support v10 locally, but advertise v9 so future Toccata-activated peers reject them. + let advertise_toccata_p2p = self.config.covenants_activation != ForkActivation::never(); + let advertised_protocol_version = if advertise_toccata_p2p { PROTOCOL_VERSION } else { 9 }; + // Build the local version message // Subnets are not currently supported - let mut self_version_message = Version::new(local_address, self.node_id, network_name.clone(), None, PROTOCOL_VERSION); + let mut self_version_message = + Version::new(local_address, self.node_id, network_name.clone(), None, advertised_protocol_version); self_version_message.add_user_agent(name(), version(), &self.config.user_agent_comments); // TODO: disable_relay_tx from config/cmd @@ -760,12 +771,41 @@ impl ConnectionInitializer for FlowContext { debug!("protocol versions - self: {}, peer: {}", PROTOCOL_VERSION, peer_version.protocol_version); - // Register all flows according to version - let (flows, applied_protocol_version) = match peer_version.protocol_version { - v if v >= PROTOCOL_VERSION => (v9::register(self.clone(), router.clone(), PROTOCOL_VERSION), PROTOCOL_VERSION), - 8 => (v8::register(self.clone(), router.clone(), 8), 8), - 7 => (v7::register(self.clone(), router.clone()), 7), - v => return Err(ProtocolError::VersionMismatch(PROTOCOL_VERSION, v)), + // TN12 launched Toccata flows under protocol 9. Normalize those peers to the current + // protocol locally while preserving the originally advertised version in peer properties. + let peer_protocol_version = if self.config.net == TN12_NETWORK && peer_version.protocol_version == TN12_LAUNCH_PROTOCOL_VERSION + { + PROTOCOL_VERSION + } else { + peer_version.protocol_version + }; + + // One day before activation, upgraded nodes start disconnecting outdated peers from the P2P network. + const ONE_DAY_SECONDS: u64 = 24 * 60 * 60; + let daa_threshold = ONE_DAY_SECONDS * self.config.bps(); + let virtual_daa_score = self.consensus().unguarded_session().get_virtual_daa_score(); + let connect_only_new_versions = self.config.covenants_activation.is_active(virtual_daa_score.saturating_add(daa_threshold)); + + // Until the one-day pre-activation threshold is reached, older protocol versions remain accepted. + // Once it is reached, peers must advertise protocol 10 (TN12 launch peers were normalized above). + // + // Note: post-activation fresh nodes with virtual DAA score near genesis are not covered here and + // are guarded later during IBD by `validate_pruning_point_freshness_for_toccata`. + let (flows, applied_protocol_version) = if connect_only_new_versions { + // Register all flows according to version + match peer_protocol_version { + v if v >= PROTOCOL_VERSION => (v10::register(self.clone(), router.clone(), PROTOCOL_VERSION), PROTOCOL_VERSION), + v => return Err(ProtocolError::VersionMismatch(PROTOCOL_VERSION, v)), + } + } else { + // Register all flows according to version + match peer_protocol_version { + v if v >= PROTOCOL_VERSION => (v10::register(self.clone(), router.clone(), PROTOCOL_VERSION), PROTOCOL_VERSION), + 9 => (v8::register(self.clone(), router.clone(), 9), 9), + 8 => (v8::register(self.clone(), router.clone(), 8), 8), + 7 => (v7::register(self.clone(), router.clone()), 7), + v => return Err(ProtocolError::VersionMismatch(PROTOCOL_VERSION, v)), + } }; // Build and register the peer properties diff --git a/protocol/flows/src/ibd/flow.rs b/protocol/flows/src/ibd/flow.rs index 6b3a174532..633a2e676a 100644 --- a/protocol/flows/src/ibd/flow.rs +++ b/protocol/flows/src/ibd/flow.rs @@ -9,6 +9,7 @@ use kaspa_consensus_core::{ BlockHashSet, api::BlockValidationFuture, block::Block, + config::params::{ForkActivation, Params}, header::Header, pruning::{PruningPointProof, PruningPointsList, PruningProofMetadata}, trusted::TrustedBlock, @@ -392,6 +393,21 @@ impl IbdFlow { } async fn sync_and_validate_pruning_proof(&mut self, staging: &ConsensusProxy, relay_block: &Block) -> Result { + // [Toccata] Guard IBD from outdated nodes. P2P flow registration does not protect + // fresh IBD peers, and the relay block is usually the syncer sink, so reject an unexpected + // block version before requesting the pruning proof. The pruning point itself is + // checked below by `validate_pruning_point_freshness_for_toccata`. + let expected_relay_block_version = self.ctx.config.block_version().get(relay_block.header.daa_score); + if relay_block.header.version != expected_relay_block_version { + return Err(ProtocolError::OtherOwned(format!( + "peer relayed block {} header version mismatch: got {}, expected {} at DAA score {} (Toccata guard)", + relay_block.hash(), + relay_block.header.version, + expected_relay_block_version, + relay_block.header.daa_score + ))); + } + self.router.enqueue(make_message!(Payload::RequestPruningPointProof, RequestPruningPointProofMessage {})).await?; // Pruning proof generation and communication might take several minutes, so we allow a long 10 minute timeout @@ -412,7 +428,8 @@ impl IbdFlow { let proof = consensus.clone().spawn_blocking(move |c| c.validate_pruning_proof(&proof, &proof_metadata).map(|()| proof)).await?; - let proof_pruning_point = proof[0].last().expect("was just ensured by validation").hash; + let proof_pruning_point_header = proof[0].last().expect("was just ensured by validation"); + let proof_pruning_point = proof_pruning_point_header.hash; if proof_pruning_point == self.ctx.config.genesis.hash { return Err(ProtocolError::Other("the proof pruning point is the genesis block")); @@ -423,6 +440,15 @@ impl IbdFlow { } drop(consensus); + // [Toccata] Reject IBD from outdated peers + validate_pruning_point_freshness_for_toccata( + self.ctx.config.as_ref(), + proof_pruning_point_header.hash, + proof_pruning_point_header.timestamp, + proof_pruning_point_header.daa_score, + unix_now(), + )?; + self.router .enqueue(make_message!(Payload::RequestPruningPointAndItsAnticone, RequestPruningPointAndItsAnticoneMessage {})) .await?; @@ -1031,3 +1057,161 @@ staging selected tip ({}) is too small or negative. Aborting IBD...", Ok(QueueChunkOutput { jobs, daa_score: current_daa_score, timestamp: current_timestamp }) } } + +/// [Toccata] Fresh nodes cannot easily identify outdated peers after activation, so we guard +/// against syncers advertising pruning points that are clearly stale. +/// +/// TODO(post-toccata): remove or adjust this stale pruning-point guard once Toccata is cleaned up. +fn validate_pruning_point_freshness_for_toccata( + params: &Params, + pp_hash: Hash, + pp_timestamp: u64, + pp_daa_score: u64, + now: u64, +) -> Result<(), ProtocolError> { + // No activation is expected. + if params.covenants_activation == ForkActivation::never() { + return Ok(()); + } + + // If the pruning point is post-activation, its header is validated as part of the pruning proof. + if params.covenants_activation.is_active(pp_daa_score) { + return Ok(()); + } + + // Otherwise, protect fresh nodes from outdated syncers with stale pre-activation pruning points. + + let activation_daa_score = params.covenants_activation.daa_score(); + + // Reject if: + // 1. the syncer's pruning point is still pre-activation; + // 2. based on its timestamp and DAA score, activation should have happened long enough ago + // for the syncer to already expose a post-activation pruning point. + const ONE_DAY_MILLIS: u64 = 24 * 60 * 60 * 1000; + let millis_per_block = params.target_time_per_block(); + + let pp_to_activation_blocks = activation_daa_score.saturating_sub(pp_daa_score); + let pp_to_activation_millis = pp_to_activation_blocks.saturating_mul(millis_per_block); + let estimated_activation_time = pp_timestamp.saturating_add(pp_to_activation_millis); + + let pruning_period_millis = params.pruning_depth().saturating_add(params.finality_depth()).saturating_mul(millis_per_block); + // The oldest activation estimate for which a pre-activation pruning point is still tolerated. + let stale_activation_time_cutoff = now.saturating_sub(pruning_period_millis).saturating_sub(ONE_DAY_MILLIS); + + // If activation should have happened before this cutoff, the syncer should already + // expose a post-activation pruning point. + if estimated_activation_time < stale_activation_time_cutoff { + return Err(ProtocolError::OtherOwned(format!( + "syncer pruning point {} is stale: DAA score {} is below Toccata activation DAA score {}, but based on its timestamp {} a post-activation pruning point is expected by now", + pp_hash, pp_daa_score, activation_daa_score, pp_timestamp + ))); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use kaspa_consensus_core::config::params::MAINNET_PARAMS; + + fn params_with_toccata_activation(activation_daa_score: u64) -> Params { + let mut params = MAINNET_PARAMS.clone(); + params.covenants_activation = ForkActivation::new(activation_daa_score); + params + } + + fn params_without_toccata_activation() -> Params { + let mut params = MAINNET_PARAMS.clone(); + params.covenants_activation = ForkActivation::never(); + params + } + + fn pruning_period_millis(params: &Params) -> u64 { + params.pruning_depth().saturating_add(params.finality_depth()).saturating_mul(params.target_time_per_block()) + } + + #[test] + fn test_toccata_pruning_point_staleness_guard() { + const ONE_DAY_MILLIS: u64 = 24 * 60 * 60 * 1000; + let activation_daa_score = 10_000_000; + let params = params_with_toccata_activation(activation_daa_score); + let blocks_per_day = ONE_DAY_MILLIS / params.target_time_per_block(); + let pp_hash = Hash::from_u64_word(1); + let pp_daa_score = activation_daa_score - 10; + let pp_timestamp = 1_000_000_000_000; + let pp_to_activation_millis = 10 * params.target_time_per_block(); + let estimated_activation_time = pp_timestamp + pp_to_activation_millis; + let stale_after = estimated_activation_time + pruning_period_millis(¶ms) + ONE_DAY_MILLIS; + + // No activation is configured: + // PP(pre-activation by score) ---- estimated activation ---- pruning period + margin ---- now + assert!( + validate_pruning_point_freshness_for_toccata( + ¶ms_without_toccata_activation(), + pp_hash, + pp_timestamp, + pp_daa_score, + stale_after + 1 + ) + .is_ok() + ); + + // Normal pre-activation IBD: activation is still ten days away. + // PP/now -------- 10d -------- activation + let pp_ten_days_before_activation = activation_daa_score - 10 * blocks_per_day; + assert!( + validate_pruning_point_freshness_for_toccata(¶ms, pp_hash, pp_timestamp, pp_ten_days_before_activation, pp_timestamp) + .is_ok() + ); + + // The syncer's pruning point is already post-activation, so the staleness guard is done: + // PP(post-activation by score) ----------------------------------------------- now + assert!( + validate_pruning_point_freshness_for_toccata(¶ms, pp_hash, pp_timestamp, activation_daa_score, stale_after + 1) + .is_ok() + ); + + // Last tolerated instant for a pre-activation pruning point: + // PP ---- estimated activation ---- pruning period + margin == now + assert!(validate_pruning_point_freshness_for_toccata(¶ms, pp_hash, pp_timestamp, pp_daa_score, stale_after).is_ok()); + + // One millisecond later, the same pre-activation pruning point is stale: + // PP ---- estimated activation ---- pruning period + margin < now + assert!(validate_pruning_point_freshness_for_toccata(¶ms, pp_hash, pp_timestamp, pp_daa_score, stale_after + 1).is_err()); + + // Stale IBD: the syncer's pruning point is three days before activation, and now is + // six days after that pruning point. Activation should have happened long enough ago + // for the syncer to already expose a post-activation pruning point. + // PP -------- 3d -------- activation -------- 3d -------- now + let pp_three_days_before_activation = activation_daa_score - 3 * blocks_per_day; + let now_six_days_after_pp = pp_timestamp + 6 * ONE_DAY_MILLIS; + assert!( + validate_pruning_point_freshness_for_toccata( + ¶ms, + pp_hash, + pp_timestamp, + pp_three_days_before_activation, + now_six_days_after_pp + ) + .is_err() + ); + + // Normal IBD: two days after activation, a pruning point just before activation is + // still expected because pruning points trail the live chain by the pruning period. + // PP - activation -------- 2d -------- now + let pp_just_before_activation = activation_daa_score - 1; + let pp_just_before_activation_timestamp = pp_timestamp + 3 * ONE_DAY_MILLIS - params.target_time_per_block(); + let now_two_days_after_activation = pp_timestamp + 5 * ONE_DAY_MILLIS; + assert!( + validate_pruning_point_freshness_for_toccata( + ¶ms, + pp_hash, + pp_just_before_activation_timestamp, + pp_just_before_activation, + now_two_days_after_activation + ) + .is_ok() + ); + } +} diff --git a/protocol/flows/src/lib.rs b/protocol/flows/src/lib.rs index 6dbcd27bbf..6070ec780f 100644 --- a/protocol/flows/src/lib.rs +++ b/protocol/flows/src/lib.rs @@ -4,6 +4,6 @@ pub mod flowcontext; pub mod ibd; pub mod service; pub mod user_agent_rule; +pub mod v10; pub mod v7; pub mod v8; -pub mod v9; diff --git a/protocol/flows/src/v9/mod.rs b/protocol/flows/src/v10/mod.rs similarity index 100% rename from protocol/flows/src/v9/mod.rs rename to protocol/flows/src/v10/mod.rs diff --git a/protocol/flows/src/v9/request_pruning_point_smt_state.rs b/protocol/flows/src/v10/request_pruning_point_smt_state.rs similarity index 100% rename from protocol/flows/src/v9/request_pruning_point_smt_state.rs rename to protocol/flows/src/v10/request_pruning_point_smt_state.rs