diff --git a/application/src/actor.rs b/application/src/actor.rs index 38dcbd57..ab63948a 100644 --- a/application/src/actor.rs +++ b/application/src/actor.rs @@ -46,7 +46,6 @@ pub struct Actor< built_block: Arc>>, genesis_hash: [u8; 32], epocher: ES, - allowed_timestamp_future_ms: u64, cancellation_token: CancellationToken, _scheme_marker: PhantomData, _key_marker: PhantomData

, @@ -77,7 +76,6 @@ impl< built_block: Arc::new(Mutex::new(None)), genesis_hash, epocher: cfg.epocher, - allowed_timestamp_future_ms: cfg.allowed_timestamp_future.as_millis() as u64, cancellation_token: cfg.cancellation_token, _scheme_marker: PhantomData, _key_marker: PhantomData, @@ -236,7 +234,6 @@ impl< let mut syncer = syncer.clone(); let mut finalizer_clone = finalizer.clone(); let epocher = self.epocher.clone(); - let allowed_timestamp_future_ms = self.allowed_timestamp_future_ms; move |context| async move { let requester = try_join(parent_request, block_request); select! { @@ -282,7 +279,7 @@ impl< } let now_millis = context.current().epoch_millis(); - if handle_verify(&block, parent, &epocher, &aux_data, now_millis, allowed_timestamp_future_ms) { + if handle_verify(&block, parent, &epocher, &aux_data, now_millis) { // persist valid block syncer.verified(round, block).await; @@ -581,7 +578,6 @@ fn handle_verify( epocher: &ES, aux_data: &BlockAuxData, now_millis: u64, - allowed_timestamp_future_ms: u64, ) -> bool { // You can only re-propose the same block if it's the last height in the epoch. if parent.digest() == block.digest() { @@ -604,10 +600,12 @@ fn handle_verify( warn!("block timestamp not increasing"); return false; } - if block.timestamp() > now_millis + allowed_timestamp_future_ms { + if block.timestamp() > now_millis + aux_data.allowed_timestamp_future_ms { warn!( block_timestamp = block.timestamp(), - now_millis, allowed_timestamp_future_ms, "block timestamp too far in the future" + now_millis, + allowed_timestamp_future_ms = aux_data.allowed_timestamp_future_ms, + "block timestamp too far in the future" ); return false; } diff --git a/application/src/config.rs b/application/src/config.rs index ce251a6c..81369df7 100644 --- a/application/src/config.rs +++ b/application/src/config.rs @@ -1,5 +1,4 @@ use commonware_consensus::types::Epocher; -use std::time::Duration; use summit_types::EngineClient; use tokio_util::sync::CancellationToken; @@ -18,11 +17,5 @@ pub struct ApplicationConfig { /// Epocher for determining epoch boundaries. pub epocher: ES, - /// Maximum allowed delta between a block's timestamp and the - /// local wall clock. Blocks with timestamps that differ from - /// the local time by more than this are rejected during - /// verification. - pub allowed_timestamp_future: Duration, - pub cancellation_token: CancellationToken, } diff --git a/docs/ssz-merklization.md b/docs/ssz-merklization.md index 9ae3be3d..008b7bc0 100644 --- a/docs/ssz-merklization.md +++ b/docs/ssz-merklization.md @@ -36,7 +36,7 @@ The state tree is a two-level design: a fixed top-level tree containing scalar f ### Top-Level Tree -32 leaf slots (depth 5), 17 used. Each leaf is a 32-byte `hash_tree_root` value. Leaves 17–31 are unused (zero-filled). +32 leaf slots (depth 5), 18 used. Each leaf is a 32-byte `hash_tree_root` value. Leaves 18–31 are unused (zero-filled). | Leaf Index | Field | Type | |------------|-------|------| @@ -51,12 +51,13 @@ The state tree is a two-level design: a fixed top-level tree containing scalar f | 8 | `forkchoice_head_block_hash` | Scalar | | 9 | `forkchoice_safe_block_hash` | Scalar | | 10 | `forkchoice_finalized_block_hash` | Scalar | -| 11 | `validator_accounts` | Collection root | -| 12 | `deposit_queue` | Collection root | -| 13 | `withdrawal_queue` | Collection root | -| 14 | `protocol_param_changes` | Collection root | -| 15 | `added_validators` | Collection root | -| 16 | `removed_validators` | Collection root | +| 11 | `allowed_timestamp_future_ms` | Scalar | +| 12 | `validator_accounts` | Collection root | +| 13 | `deposit_queue` | Collection root | +| 14 | `withdrawal_queue` | Collection root | +| 15 | `protocol_param_changes` | Collection root | +| 16 | `added_validators` | Collection root | +| 17 | `removed_validators` | Collection root | ### Collection Subtrees @@ -141,7 +142,7 @@ A `HashMap` index enables O(1) proof lookup by All leaf values are 32 bytes, produced by SSZ `hash_tree_root`: -- **`u64`**: Little-endian encoded, zero-padded to 32 bytes. Used by: epoch, view, latest_height, balance, amount, index, joining_epoch, last_deposit_index, next_withdrawal_index, minimum/maximum_stake, validator_index, balance_deduction. +- **`u64`**: Little-endian encoded, zero-padded to 32 bytes. Used by: epoch, view, latest_height, balance, amount, index, joining_epoch, last_deposit_index, next_withdrawal_index, minimum/maximum_stake, allowed_timestamp_future_ms, validator_index, balance_deduction. - **`bool`**: `0x01` or `0x00`, zero-padded to 32 bytes. Used by: has_pending_deposit, has_pending_withdrawal. - **`ValidatorStatus` (enum)**: Single byte (Active=0, Inactive=1, SubmittedExitRequest=2, Joining=3), zero-padded to 32 bytes. - **`[u8; 32]`**: Used directly as the leaf value. Used by: head_digest, epoch_genesis_hash, forkchoice hashes, withdrawal_credentials (deposit), pubkey (withdrawal). @@ -168,6 +169,7 @@ Single top-level leaf write + rehash of the 5-level path to root. | `set_epoch_genesis_hash()` | `ssz_tree.set_epoch_genesis_hash()` | | `set_minimum_stake()` | `ssz_tree.set_validator_minimum_stake()` | | `set_maximum_stake()` | `ssz_tree.set_validator_maximum_stake()` | +| `set_allowed_timestamp_future_ms()` | `ssz_tree.set_allowed_timestamp_future_ms()` | | `set_next_withdrawal_index()` | `ssz_tree.set_next_withdrawal_index()` | | `set_forkchoice_head()` | `ssz_tree.set_forkchoice_head_block_hash()` | | `set_forkchoice_safe_and_finalized()` | Two setter calls (safe + finalized) | @@ -392,6 +394,7 @@ Keys are human-readable strings parsed by `types/src/ssz_tree_key.rs`: | `epoch_genesis_hash` | Genesis hash for current epoch | | `validator_minimum_stake` | Minimum validator stake | | `validator_maximum_stake` | Maximum validator stake | +| `allowed_timestamp_future_ms` | Allowed timestamp future (ms) | | `next_withdrawal_index` | Next withdrawal index | | `forkchoice_head_block_hash` | Forkchoice head hash | | `forkchoice_safe_block_hash` | Forkchoice safe hash | diff --git a/example_genesis.toml b/example_genesis.toml index 911136c8..bb5683a4 100644 --- a/example_genesis.toml +++ b/example_genesis.toml @@ -9,6 +9,7 @@ namespace = "_SUMMIT" validator_minimum_stake = 32000000000 validator_maximum_stake = 32000000000 blocks_per_epoch = 10000 +allowed_timestamp_future_ms = 10000 [[validators]] node_public_key = "1be3cb06d7cc347602421fb73838534e4b54934e28959de98906d120d0799ef2" diff --git a/finalizer/src/actor.rs b/finalizer/src/actor.rs index b215589c..d8d73ef9 100644 --- a/finalizer/src/actor.rs +++ b/finalizer/src/actor.rs @@ -889,6 +889,7 @@ impl< forkchoice: *state.get_forkchoice(), withdrawal_credentials, state_root: state.get_state_root(), + allowed_timestamp_future_ms: state.get_allowed_timestamp_future_ms(), } } else { BlockAuxData { @@ -901,6 +902,7 @@ impl< forkchoice: *state.get_forkchoice(), withdrawal_credentials, state_root: state.get_state_root(), + allowed_timestamp_future_ms: state.get_allowed_timestamp_future_ms(), } }; trace!( @@ -970,6 +972,10 @@ impl< let length = self.canonical_state.get_epocher().current_length(); let _ = sender.send(ConsensusStateResponse::EpochLength(length)); } + ConsensusStateRequest::GetAllowedTimestampFuture => { + let ms = self.canonical_state.get_allowed_timestamp_future_ms(); + let _ = sender.send(ConsensusStateResponse::AllowedTimestampFuture(ms)); + } ConsensusStateRequest::GetEpochBounds(epoch) => { let bounds = self .canonical_state diff --git a/finalizer/src/ingress.rs b/finalizer/src/ingress.rs index 4dbb5795..18583353 100644 --- a/finalizer/src/ingress.rs +++ b/finalizer/src/ingress.rs @@ -279,6 +279,24 @@ impl, B: ConsensusBlock> FinalizerMailbox { length } + pub async fn get_allowed_timestamp_future(&self) -> u64 { + let (response, rx) = oneshot::channel(); + let request = ConsensusStateRequest::GetAllowedTimestampFuture; + let _ = self + .sender + .clone() + .send(FinalizerMessage::QueryState { request, response }) + .await; + + let res = rx + .await + .expect("consensus state query response sender dropped"); + let ConsensusStateResponse::AllowedTimestampFuture(ms) = res else { + unreachable!("request and response variants must match"); + }; + ms + } + pub async fn get_epoch_bounds(&self, epoch: u64) -> Option<(u64, u64)> { let (response, rx) = oneshot::channel(); let request = ConsensusStateRequest::GetEpochBounds(epoch); diff --git a/finalizer/src/tests/fork_handling.rs b/finalizer/src/tests/fork_handling.rs index 117adf36..39473fab 100644 --- a/finalizer/src/tests/fork_handling.rs +++ b/finalizer/src/tests/fork_handling.rs @@ -113,7 +113,13 @@ fn create_test_initial_state(genesis_hash: [u8; 32], epoch_length: NonZeroU64) - safe_block_hash: genesis_hash.into(), finalized_block_hash: genesis_hash.into(), }; - let mut state = ConsensusState::new(forkchoice, 32_000_000_000, 64_000_000_000, epoch_length); + let mut state = ConsensusState::new( + forkchoice, + 32_000_000_000, + 64_000_000_000, + epoch_length, + 10_000, + ); state.set_validator_accounts(validator_accounts); state } diff --git a/finalizer/src/tests/state_queries.rs b/finalizer/src/tests/state_queries.rs index 7d03c046..e716b88c 100644 --- a/finalizer/src/tests/state_queries.rs +++ b/finalizer/src/tests/state_queries.rs @@ -119,7 +119,13 @@ fn create_test_initial_state(genesis_hash: [u8; 32], epoch_length: NonZeroU64) - safe_block_hash: genesis_hash.into(), finalized_block_hash: genesis_hash.into(), }; - let mut state = ConsensusState::new(forkchoice, 32_000_000_000, 64_000_000_000, epoch_length); + let mut state = ConsensusState::new( + forkchoice, + 32_000_000_000, + 64_000_000_000, + epoch_length, + 10_000, + ); state.set_validator_accounts(validator_accounts); state } diff --git a/finalizer/src/tests/syncing.rs b/finalizer/src/tests/syncing.rs index 1bfe5336..19d5b427 100644 --- a/finalizer/src/tests/syncing.rs +++ b/finalizer/src/tests/syncing.rs @@ -113,7 +113,13 @@ fn create_test_initial_state(genesis_hash: [u8; 32], epoch_length: NonZeroU64) - safe_block_hash: genesis_hash.into(), finalized_block_hash: genesis_hash.into(), }; - let mut state = ConsensusState::new(forkchoice, 32_000_000_000, 64_000_000_000, epoch_length); + let mut state = ConsensusState::new( + forkchoice, + 32_000_000_000, + 64_000_000_000, + epoch_length, + 10_000, + ); state.set_validator_accounts(validator_accounts); state } diff --git a/finalizer/src/tests/validator_lifecycle.rs b/finalizer/src/tests/validator_lifecycle.rs index 64bbb5af..7f2a27e0 100644 --- a/finalizer/src/tests/validator_lifecycle.rs +++ b/finalizer/src/tests/validator_lifecycle.rs @@ -119,7 +119,13 @@ fn create_test_initial_state(genesis_hash: [u8; 32], epoch_length: NonZeroU64) - safe_block_hash: genesis_hash.into(), finalized_block_hash: genesis_hash.into(), }; - let mut state = ConsensusState::new(forkchoice, 32_000_000_000, 64_000_000_000, epoch_length); + let mut state = ConsensusState::new( + forkchoice, + 32_000_000_000, + 64_000_000_000, + epoch_length, + 10_000, + ); state.set_validator_accounts(validator_accounts); state } diff --git a/node/src/args.rs b/node/src/args.rs index a5e89bac..4ce73db1 100644 --- a/node/src/args.rs +++ b/node/src/args.rs @@ -285,6 +285,7 @@ impl Command { genesis.validator_maximum_stake, NonZeroU64::new(genesis.blocks_per_epoch) .expect("blocks_per_epoch must be nonzero"), + genesis.allowed_timestamp_future_ms, ); let peers = initial_state.get_validator_keys(); @@ -502,6 +503,7 @@ pub fn run_node_local( genesis.validator_minimum_stake, genesis.validator_maximum_stake, NonZeroU64::new(genesis.blocks_per_epoch).expect("blocks_per_epoch must be nonzero"), + genesis.allowed_timestamp_future_ms, ); let peers = initial_state.get_validator_keys(); @@ -660,6 +662,7 @@ fn get_initial_state( validator_minimum_stake: u64, validator_maximum_stake: u64, epoch_length: NonZeroU64, + allowed_timestamp_future_ms: u64, ) -> ConsensusState { let genesis_hash: B256 = genesis_hash.into(); checkpoint.unwrap_or_else(|| { @@ -673,6 +676,7 @@ fn get_initial_state( validator_minimum_stake, validator_maximum_stake, epoch_length, + allowed_timestamp_future_ms, ); // Add the genesis nodes to the consensus state with the minimum stake balance. for validator in genesis_committee { diff --git a/node/src/bin/genesis.rs b/node/src/bin/genesis.rs index 6a43662e..8243fb52 100644 --- a/node/src/bin/genesis.rs +++ b/node/src/bin/genesis.rs @@ -17,6 +17,7 @@ pub struct GenesisConfig { max_message_size_bytes: u64, namespace: String, blocks_per_epoch: u64, + allowed_timestamp_future_ms: u64, pub validators: Vec, } diff --git a/node/src/bin/stake_and_checkpoint.rs b/node/src/bin/stake_and_checkpoint.rs index 06dedbfd..371289fd 100644 --- a/node/src/bin/stake_and_checkpoint.rs +++ b/node/src/bin/stake_and_checkpoint.rs @@ -65,10 +65,10 @@ struct Args { #[arg(long, default_value = "/tmp/summit_checkpointing_test")] pub data_dir: String, /// Height at which the joining node will download the checkpoint - #[arg(long, default_value_t = 1000)] + #[arg(long, default_value_t = 50)] pub checkpoint_height: u64, /// Height that all nodes must reach for the test to succeed - #[arg(long, default_value_t = 2000)] + #[arg(long, default_value_t = 100)] pub stop_height: u64, } diff --git a/node/src/engine.rs b/node/src/engine.rs index 3fb0bb50..ec33a68e 100644 --- a/node/src/engine.rs +++ b/node/src/engine.rs @@ -42,7 +42,6 @@ const BUFFER_POOL_PAGE_SIZE: u16 = 4_096; // 4KB const BUFFER_POOL_CAPACITY: NonZero = NZUsize!(8_192); // 32MB const PRUNABLE_ITEMS_PER_SECTION: NonZero = NZU64!(4_096); const IMMUTABLE_ITEMS_PER_SECTION: NonZero = NZU64!(262_144); -const ALLOWED_TIMESTAMP_FUTURE: Duration = Duration::from_secs(10); const FREEZER_TABLE_RESIZE_FREQUENCY: u8 = 4; const FREEZER_TABLE_RESIZE_CHUNK_SIZE: u32 = 2u32.pow(16); // 3MB const FREEZER_JOURNAL_TARGET_SIZE: u64 = 1024 * 1024 * 1024; // 1GB @@ -174,7 +173,6 @@ where partition_prefix: cfg.partition_prefix.clone(), genesis_hash: cfg.genesis_hash, epocher: epocher.clone(), - allowed_timestamp_future: ALLOWED_TIMESTAMP_FUTURE, cancellation_token: cancellation_token.clone(), }, ) diff --git a/node/src/test_harness/common.rs b/node/src/test_harness/common.rs index edfe964f..23419f04 100644 --- a/node/src/test_harness/common.rs +++ b/node/src/test_harness/common.rs @@ -346,6 +346,7 @@ pub fn get_initial_state( balance, balance, NonZeroU64::new(DEFAULT_BLOCKS_PER_EPOCH).unwrap(), + 10_000, // 10 seconds ); // Add the genesis nodes to the consensus state with the minimum stake balance. for ((node_pubkey, consensus_pubkey), address) in committee.iter().zip(addresses.iter()) { diff --git a/node/src/tests/checkpointing/verification.rs b/node/src/tests/checkpointing/verification.rs index ed5b19cc..97cc597d 100644 --- a/node/src/tests/checkpointing/verification.rs +++ b/node/src/tests/checkpointing/verification.rs @@ -96,6 +96,7 @@ fn test_checkpoint_verification_fixed_committee() { validator_minimum_stake: 32_000_000_000, validator_maximum_stake: 32_000_000_000, blocks_per_epoch: common::DEFAULT_BLOCKS_PER_EPOCH, + allowed_timestamp_future_ms: 10_000, }; let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); @@ -312,6 +313,7 @@ fn test_checkpoint_verification_dynamic_committee() { validator_minimum_stake: min_stake, validator_maximum_stake: min_stake, blocks_per_epoch: common::DEFAULT_BLOCKS_PER_EPOCH, + allowed_timestamp_future_ms: 10_000, }; let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); diff --git a/node/src/tests/execution_requests/protocol_params.rs b/node/src/tests/execution_requests/protocol_params.rs index 36569fd7..6c77bd41 100644 --- a/node/src/tests/execution_requests/protocol_params.rs +++ b/node/src/tests/execution_requests/protocol_params.rs @@ -1,5 +1,168 @@ use super::*; +#[test_traced("INFO")] +fn test_protocol_param_allowed_timestamp_future() { + // Adds a protocol param request for allowed_timestamp_future to the block at height 5 + // and verifies that the value is changed at the end of the epoch. + let n = 5; + let min_stake = 32_000_000_000; + let link = Link { + latency: Duration::from_millis(80), + jitter: Duration::from_millis(10), + success_rate: 0.98, + }; + // Create context + let cfg = deterministic::Config::default().with_seed(0); + let executor = Runner::from(cfg); + executor.start(|context| async move { + // Create simulated network + let (network, mut oracle) = Network::new( + context.with_label("network"), + simulated::Config { + max_size: 1024 * 1024, + disconnect_on_block: false, + tracked_peer_sets: Some(n as usize * 10), + }, + ); + + network.start(); + + let mut key_stores = Vec::new(); + let mut validators = Vec::new(); + for i in 0..n { + let mut rng = StdRng::seed_from_u64(i as u64); + let node_key = PrivateKey::random(&mut rng); + let node_public_key = node_key.public_key(); + let consensus_key = bls12381::PrivateKey::random(&mut rng); + let consensus_public_key = consensus_key.public_key(); + let key_store = KeyStore { + node_key, + consensus_key, + }; + key_stores.push(key_store); + validators.push((node_public_key, consensus_public_key)); + } + validators.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); + key_stores.sort_by_key(|ks| ks.node_key.public_key()); + + let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); + let mut registrations = common::register_validators(&oracle, &node_public_keys).await; + + common::link_validators(&mut oracle, &node_public_keys, link, None).await; + + let genesis_hash = + from_hex_formatted(common::GENESIS_HASH).expect("failed to decode genesis hash"); + let genesis_hash: [u8; 32] = genesis_hash + .try_into() + .expect("failed to convert genesis hash"); + + // Create a protocol param request for allowed_timestamp_future (param_id 0x03) + let new_allowed_timestamp_future = 30_000u64; // 30 seconds + let test_protocol_param = + common::create_protocol_param_request(0x03, new_allowed_timestamp_future); + + let execution_requests = vec![ExecutionRequest::ProtocolParam(test_protocol_param)]; + let requests = common::execution_requests_to_requests(execution_requests); + + let protocol_param_block_height = 5; + let stop_height = DEFAULT_BLOCKS_PER_EPOCH + 1; + let mut execution_requests_map = HashMap::new(); + execution_requests_map.insert(protocol_param_block_height, requests); + + let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) + .with_execution_requests(execution_requests_map) + .with_stop_at(stop_height) + .build(); + let initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); + + let mut public_keys = HashSet::new(); + let mut consensus_state_queries = HashMap::new(); + for (idx, key_store) in key_stores.into_iter().enumerate() { + let public_key = key_store.node_key.public_key(); + public_keys.insert(public_key.clone()); + + let uid = format!("validator_{public_key}"); + let namespace = String::from("_SUMMIT"); + + let engine_client = engine_client_network.create_client(uid.clone()); + + let config = get_default_engine_config( + engine_client, + SimulatedOracle::new(oracle.clone()), + uid.clone(), + genesis_hash, + namespace, + key_store, + validators.clone(), + initial_state.clone(), + ); + let engine = Engine::new(context.with_label(&uid), config).await; + consensus_state_queries.insert(idx, engine.finalizer_mailbox.clone()); + + let (pending, recovered, resolver, orchestrator, broadcast) = + registrations.remove(&public_key).unwrap(); + + engine.start(pending, recovered, resolver, orchestrator, broadcast); + } + + // Poll metrics + let mut height_reached = HashSet::new(); + loop { + let metrics = context.encode(); + let mut success = false; + for line in metrics.lines() { + if !line.starts_with("validator_") { + continue; + } + + let mut parts = line.split_whitespace(); + let metric = parts.next().unwrap(); + let value = parts.next().unwrap(); + + if metric.ends_with("_peers_blocked") { + let value = value.parse::().unwrap(); + assert_eq!(value, 0); + } + + if metric.ends_with("finalizer_height") { + let height = value.parse::().unwrap(); + if height >= stop_height { + height_reached.insert(metric.to_string()); + } + } + + if height_reached.len() as u32 == n { + success = true; + break; + } + } + if success { + break; + } + + context.sleep(Duration::from_secs(1)).await; + } + + // Check that allowed_timestamp_future was updated + let state_query = consensus_state_queries.get(&0).unwrap(); + assert_eq!( + state_query.get_allowed_timestamp_future().await, + new_allowed_timestamp_future + ); + + // Check that all nodes have the same canonical chain + assert!( + engine_client_network + .verify_consensus(None, Some(stop_height)) + .is_ok() + ); + + common::assert_state_root_consensus(&consensus_state_queries).await; + + context.auditor().state() + }) +} + #[test_traced("INFO")] fn test_protocol_param_max_stake() { // Adds a protocol param request for maximum stake to the block at height 5 diff --git a/rpc/src/api.rs b/rpc/src/api.rs index c0ed24e4..432efb27 100644 --- a/rpc/src/api.rs +++ b/rpc/src/api.rs @@ -57,6 +57,9 @@ pub trait SummitApi { #[method(name = "getEpochLength")] async fn get_epoch_length(&self) -> RpcResult; + #[method(name = "getAllowedTimestampFuture")] + async fn get_allowed_timestamp_future(&self) -> RpcResult; + #[method(name = "getEpochBounds")] async fn get_epoch_bounds(&self, epoch: u64) -> RpcResult; diff --git a/rpc/src/server.rs b/rpc/src/server.rs index 7016da8f..26760bd0 100644 --- a/rpc/src/server.rs +++ b/rpc/src/server.rs @@ -289,6 +289,11 @@ impl SummitApiServer for SummitRpcServer { Ok(epoch_length) } + async fn get_allowed_timestamp_future(&self) -> RpcResult { + let ms = self.finalizer_mailbox.get_allowed_timestamp_future().await; + Ok(ms) + } + async fn get_epoch_bounds(&self, epoch: u64) -> RpcResult { let bounds = self.finalizer_mailbox.get_epoch_bounds(epoch).await; match bounds { diff --git a/rpc/tests/utils.rs b/rpc/tests/utils.rs index b2c78399..33768455 100644 --- a/rpc/tests/utils.rs +++ b/rpc/tests/utils.rs @@ -127,6 +127,10 @@ pub fn create_test_finalizer_mailbox( ConsensusStateRequest::GetEpochLength => { let _ = response.send(ConsensusStateResponse::EpochLength(10)); } + ConsensusStateRequest::GetAllowedTimestampFuture => { + let _ = + response.send(ConsensusStateResponse::AllowedTimestampFuture(10_000)); + } ConsensusStateRequest::GetEpochBounds(epoch) => { let first = epoch * 10; let last = first + 9; diff --git a/types/src/checkpoint.rs b/types/src/checkpoint.rs index c3f440ee..7616a945 100644 --- a/types/src/checkpoint.rs +++ b/types/src/checkpoint.rs @@ -366,6 +366,7 @@ mod tests { epoch_genesis_hash: [0u8; 32], validator_minimum_stake: 32_000_000_000, // 32 ETH in gwei validator_maximum_stake: 32_000_000_000, // 32 ETH in gwei + allowed_timestamp_future_ms: 10_000, epocher: DynamicEpocher::new(NonZeroU64::new(10).unwrap()), ssz_tree: SszStateTree::default(), proof_tree: SszStateTree::default(), @@ -490,6 +491,7 @@ mod tests { epoch_genesis_hash: [0u8; 32], validator_minimum_stake: 32_000_000_000, // 32 ETH in gwei validator_maximum_stake: 32_000_000_000, // 32 ETH in gwei + allowed_timestamp_future_ms: 10_000, epocher: DynamicEpocher::new(NonZeroU64::new(10).unwrap()), ssz_tree: SszStateTree::default(), proof_tree: SszStateTree::default(), @@ -538,6 +540,7 @@ mod tests { epoch_genesis_hash: [0u8; 32], validator_minimum_stake: 32_000_000_000, // 32 ETH in gwei validator_maximum_stake: 32_000_000_000, // 32 ETH in gwei + allowed_timestamp_future_ms: 10_000, epocher: DynamicEpocher::new(NonZeroU64::new(10).unwrap()), ssz_tree: SszStateTree::default(), proof_tree: SszStateTree::default(), @@ -669,6 +672,7 @@ mod tests { epoch_genesis_hash: [0u8; 32], validator_minimum_stake: 32_000_000_000, // 32 ETH in gwei validator_maximum_stake: 32_000_000_000, // 32 ETH in gwei + allowed_timestamp_future_ms: 10_000, epocher: DynamicEpocher::new(NonZeroU64::new(10).unwrap()), ssz_tree: SszStateTree::default(), proof_tree: SszStateTree::default(), @@ -722,6 +726,7 @@ mod tests { epoch_genesis_hash: [0u8; 32], validator_minimum_stake: 32_000_000_000, // 32 ETH in gwei validator_maximum_stake: 32_000_000_000, // 32 ETH in gwei + allowed_timestamp_future_ms: 10_000, epocher: DynamicEpocher::new(NonZeroU64::new(10).unwrap()), ssz_tree: SszStateTree::default(), proof_tree: SszStateTree::default(), @@ -780,6 +785,7 @@ mod tests { epoch_genesis_hash: [0u8; 32], validator_minimum_stake: 32_000_000_000, // 32 ETH in gwei validator_maximum_stake: 32_000_000_000, // 32 ETH in gwei + allowed_timestamp_future_ms: 10_000, epocher: DynamicEpocher::new(NonZeroU64::new(10).unwrap()), ssz_tree: SszStateTree::default(), proof_tree: SszStateTree::default(), @@ -834,6 +840,7 @@ mod tests { epoch_genesis_hash: [0u8; 32], validator_minimum_stake: 32_000_000_000, // 32 ETH in gwei validator_maximum_stake: 32_000_000_000, // 32 ETH in gwei + allowed_timestamp_future_ms: 10_000, epocher: DynamicEpocher::new(NonZeroU64::new(10).unwrap()), ssz_tree: SszStateTree::default(), proof_tree: SszStateTree::default(), @@ -933,6 +940,7 @@ mod tests { epoch_genesis_hash: [0u8; 32], validator_minimum_stake: 32_000_000_000, // 32 ETH in gwei validator_maximum_stake: 32_000_000_000, // 32 ETH in gwei + allowed_timestamp_future_ms: 10_000, epocher: DynamicEpocher::new(NonZeroU64::new(10).unwrap()), ssz_tree: SszStateTree::default(), proof_tree: SszStateTree::default(), diff --git a/types/src/consensus_state.rs b/types/src/consensus_state.rs index 1953eb6b..d6788039 100644 --- a/types/src/consensus_state.rs +++ b/types/src/consensus_state.rs @@ -36,6 +36,7 @@ pub struct ConsensusState { pub(crate) epoch_genesis_hash: [u8; 32], pub(crate) validator_minimum_stake: u64, // in gwei pub(crate) validator_maximum_stake: u64, // in gwei + pub(crate) allowed_timestamp_future_ms: u64, pub(crate) epocher: DynamicEpocher, /// In-memory SSZ binary Merkle tree over the entire consensus state. @@ -82,6 +83,7 @@ impl Default for ConsensusState { epoch_genesis_hash: [0u8; 32], validator_minimum_stake: 32_000_000_000, // 32 ETH in gwei validator_maximum_stake: 32_000_000_000, // 32 ETH in gwei + allowed_timestamp_future_ms: 50, epocher: DynamicEpocher::new(NonZeroU64::new(1).unwrap()), ssz_tree: SszStateTree::default(), proof_tree: SszStateTree::default(), @@ -101,6 +103,7 @@ impl ConsensusState { validator_minimum_stake: u64, validator_maximum_stake: u64, epoch_length: NonZeroU64, + allowed_timestamp_future_ms: u64, ) -> Self { let mut s = Self { epoch: 0, @@ -119,6 +122,7 @@ impl ConsensusState { epoch_genesis_hash: forkchoice.head_block_hash.into(), validator_minimum_stake, validator_maximum_stake, + allowed_timestamp_future_ms, epocher: DynamicEpocher::new(epoch_length), ssz_tree: SszStateTree::default(), proof_tree: SszStateTree::default(), @@ -189,6 +193,15 @@ impl ConsensusState { self.ssz_tree.set_validator_maximum_stake(stake); } + pub fn get_allowed_timestamp_future_ms(&self) -> u64 { + self.allowed_timestamp_future_ms + } + + pub fn set_allowed_timestamp_future_ms(&mut self, ms: u64) { + self.allowed_timestamp_future_ms = ms; + self.ssz_tree.set_allowed_timestamp_future_ms(ms); + } + pub fn get_pending_checkpoint(&self) -> Option<&Checkpoint> { self.pending_checkpoint.as_ref() } @@ -643,6 +656,10 @@ impl ConsensusState { .update_length(new_length) .expect("failed to update epoch length"); } + ProtocolParam::AllowedTimestampFuture(ms) => { + self.allowed_timestamp_future_ms = ms; + self.ssz_tree.set_allowed_timestamp_future_ms(ms); + } } } // Protocol param changes have been consumed — update the (now empty) collection root @@ -666,6 +683,7 @@ impl ConsensusState { &self.epoch_genesis_hash, self.validator_minimum_stake, self.validator_maximum_stake, + self.allowed_timestamp_future_ms, self.withdrawal_queue.next_index(), &self.forkchoice.head_block_hash.0, &self.forkchoice.safe_block_hash.0, @@ -723,6 +741,7 @@ impl EncodeSize for ConsensusState { + 32 // head_digest + 8 // validator_minimum_stake + 8 // validator_maximum_stake + + 8 // allowed_timestamp_future_ms + self.epocher.encode_size() } } @@ -824,6 +843,7 @@ impl Read for ConsensusState { let validator_minimum_stake = buf.get_u64(); let validator_maximum_stake = buf.get_u64(); + let allowed_timestamp_future_ms = buf.get_u64(); let epocher = DynamicEpocher::read_cfg(buf, &())?; @@ -844,6 +864,7 @@ impl Read for ConsensusState { epoch_genesis_hash, validator_minimum_stake, validator_maximum_stake, + allowed_timestamp_future_ms, epocher, ssz_tree: SszStateTree::default(), proof_tree: SszStateTree::default(), @@ -927,6 +948,7 @@ impl Write for ConsensusState { // Write validator stake bounds buf.put_u64(self.validator_minimum_stake); buf.put_u64(self.validator_maximum_stake); + buf.put_u64(self.allowed_timestamp_future_ms); // Write epocher self.epocher.write(buf); @@ -1042,6 +1064,7 @@ mod tests { 0, 0, NonZeroU64::new(100).unwrap(), + 10_000, ); original_state.set_epoch(7); @@ -1798,6 +1821,7 @@ mod tests { 32_000_000_000, 32_000_000_000, NonZeroU64::new(10).unwrap(), + 10_000, ); // Add 4 genesis validators (like the testnet) diff --git a/types/src/consensus_state_query.rs b/types/src/consensus_state_query.rs index 0aeb60da..93c342b9 100644 --- a/types/src/consensus_state_query.rs +++ b/types/src/consensus_state_query.rs @@ -21,6 +21,7 @@ pub enum ConsensusStateRequest { GetMinimumStake, GetMaximumStake, GetEpochLength, + GetAllowedTimestampFuture, GetEpochBounds(u64), GetDeposit(usize), GetDepositCount, @@ -40,6 +41,7 @@ pub enum ConsensusStateResponse { MinimumStake(u64), MaximumStake(u64), EpochLength(u64), + AllowedTimestampFuture(u64), EpochBounds(Option<(u64, u64)>), Deposit(Option), DepositCount(usize), @@ -221,6 +223,20 @@ impl ConsensusStateQuery { length } + pub async fn get_allowed_timestamp_future(&self) -> u64 { + let (tx, rx) = oneshot::channel(); + let req = ConsensusStateRequest::GetAllowedTimestampFuture; + let _ = self.sender.clone().send((req, tx)).await; + + let res = rx + .await + .expect("consensus state query response sender dropped"); + let ConsensusStateResponse::AllowedTimestampFuture(ms) = res else { + unreachable!("request and response variants must match"); + }; + ms + } + pub async fn get_epoch_bounds(&self, epoch: u64) -> Option<(u64, u64)> { let (tx, rx) = oneshot::channel(); let req = ConsensusStateRequest::GetEpochBounds(epoch); diff --git a/types/src/genesis.rs b/types/src/genesis.rs index 7ce24bf1..d162d528 100644 --- a/types/src/genesis.rs +++ b/types/src/genesis.rs @@ -1,4 +1,5 @@ use crate::PublicKey; +use crate::protocol_params::{MAX_ALLOWED_TIMESTAMP_FUTURE_MS, MIN_ALLOWED_TIMESTAMP_FUTURE_MS}; use alloy_primitives::Address; use anyhow::Context; use commonware_codec::DecodeExt; @@ -44,6 +45,10 @@ pub struct Genesis { pub validator_maximum_stake: u64, /// Number of blocks in each epoch pub blocks_per_epoch: u64, + /// Maximum allowed delta (in milliseconds) between a block's timestamp + /// and the local wall clock. Blocks with timestamps that exceed local + /// time by more than this are rejected during verification. + pub allowed_timestamp_future_ms: u64, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -101,6 +106,15 @@ impl Genesis { if genesis.blocks_per_epoch == 0 { return Err("blocks_per_epoch must be greater than 0".into()); } + if genesis.allowed_timestamp_future_ms < MIN_ALLOWED_TIMESTAMP_FUTURE_MS + || genesis.allowed_timestamp_future_ms > MAX_ALLOWED_TIMESTAMP_FUTURE_MS + { + return Err(format!( + "allowed_timestamp_future_ms must be between {} and {}", + MIN_ALLOWED_TIMESTAMP_FUTURE_MS, MAX_ALLOWED_TIMESTAMP_FUTURE_MS + ) + .into()); + } Ok(genesis) } diff --git a/types/src/lib.rs b/types/src/lib.rs index d4acad26..a2714c0a 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -52,6 +52,7 @@ pub struct BlockAuxData { pub forkchoice: ForkchoiceState, pub withdrawal_credentials: Address, pub state_root: [u8; 32], + pub allowed_timestamp_future_ms: u64, } pub use commonware_cryptography::bls12381; diff --git a/types/src/protocol_params.rs b/types/src/protocol_params.rs index 149a354a..6f591ef3 100644 --- a/types/src/protocol_params.rs +++ b/types/src/protocol_params.rs @@ -5,12 +5,15 @@ use commonware_codec::{EncodeSize, Error, Read, Write}; pub const MIN_EPOCH_LENGTH: u64 = 10; pub const MAX_EPOCH_LENGTH: u64 = 1_814_400; +pub const MIN_ALLOWED_TIMESTAMP_FUTURE_MS: u64 = 1_000; +pub const MAX_ALLOWED_TIMESTAMP_FUTURE_MS: u64 = 600_000; #[derive(Clone, Debug)] pub enum ProtocolParam { MinimumStake(u64), MaximumStake(u64), EpochLength(u64), + AllowedTimestampFuture(u64), } impl TryFrom for ProtocolParam { @@ -62,6 +65,29 @@ impl TryFrom for ProtocolParam { } Ok(ProtocolParam::EpochLength(epoch_length)) } + 0x03 => { + if request.param.len() != 8 { + return Err(anyhow!( + "Failed to parse allowed timestamp future protocol param, invalid length {}", + request.param.len() + )); + } + let bytes: [u8; 8] = request.param.as_slice().try_into()?; + let allowed_timestamp_future = u64::from_le_bytes(bytes); + if allowed_timestamp_future < MIN_ALLOWED_TIMESTAMP_FUTURE_MS { + return Err(anyhow!( + "Allowed timestamp future {allowed_timestamp_future}ms is below minimum {MIN_ALLOWED_TIMESTAMP_FUTURE_MS}ms" + )); + } + if allowed_timestamp_future > MAX_ALLOWED_TIMESTAMP_FUTURE_MS { + return Err(anyhow!( + "Allowed timestamp future {allowed_timestamp_future}ms exceeds maximum {MAX_ALLOWED_TIMESTAMP_FUTURE_MS}ms" + )); + } + Ok(ProtocolParam::AllowedTimestampFuture( + allowed_timestamp_future, + )) + } _ => Err(anyhow!( "Failed to parse protocol param request - unknown param_id: {request:?}" )), @@ -90,6 +116,10 @@ impl Write for ProtocolParam { buf.put_u8(0x02); buf.put_u64(*value); } + ProtocolParam::AllowedTimestampFuture(value) => { + buf.put_u8(0x03); + buf.put_u64(*value); + } } } } @@ -112,6 +142,17 @@ impl Read for ProtocolParam { } Ok(ProtocolParam::EpochLength(value)) } + 0x03 => { + if !(MIN_ALLOWED_TIMESTAMP_FUTURE_MS..=MAX_ALLOWED_TIMESTAMP_FUTURE_MS) + .contains(&value) + { + return Err(Error::Invalid( + "ProtocolParam", + "allowed timestamp future out of bounds", + )); + } + Ok(ProtocolParam::AllowedTimestampFuture(value)) + } _ => Err(Error::Invalid("ProtocolParam", "unknown tag")), } } diff --git a/types/src/ssz_hash.rs b/types/src/ssz_hash.rs index eecc8b74..19f02487 100644 --- a/types/src/ssz_hash.rs +++ b/types/src/ssz_hash.rs @@ -98,6 +98,7 @@ impl SszHashTreeRoot for ProtocolParam { ProtocolParam::MinimumStake(v) => (0u64, *v), ProtocolParam::MaximumStake(v) => (1u64, *v), ProtocolParam::EpochLength(v) => (2u64, *v), + ProtocolParam::AllowedTimestampFuture(v) => (3u64, *v), }; merkleize(&[tag.hash_tree_root(), value.hash_tree_root()]) } diff --git a/types/src/ssz_state_tree.rs b/types/src/ssz_state_tree.rs index 5752c319..b858f205 100644 --- a/types/src/ssz_state_tree.rs +++ b/types/src/ssz_state_tree.rs @@ -39,15 +39,16 @@ pub const NEXT_WITHDRAWAL_INDEX: usize = 7; pub const FORKCHOICE_HEAD_BLOCK_HASH: usize = 8; pub const FORKCHOICE_SAFE_BLOCK_HASH: usize = 9; pub const FORKCHOICE_FINALIZED_BLOCK_HASH: usize = 10; -pub const VALIDATOR_ACCOUNTS_ROOT: usize = 11; -pub const DEPOSIT_QUEUE_ROOT: usize = 12; -pub const WITHDRAWAL_QUEUE_ROOT: usize = 13; -pub const PROTOCOL_PARAM_CHANGES_ROOT: usize = 14; -pub const ADDED_VALIDATORS_ROOT: usize = 15; -pub const REMOVED_VALIDATORS_ROOT: usize = 16; +pub const ALLOWED_TIMESTAMP_FUTURE_MS: usize = 11; +pub const VALIDATOR_ACCOUNTS_ROOT: usize = 12; +pub const DEPOSIT_QUEUE_ROOT: usize = 13; +pub const WITHDRAWAL_QUEUE_ROOT: usize = 14; +pub const PROTOCOL_PARAM_CHANGES_ROOT: usize = 15; +pub const ADDED_VALIDATORS_ROOT: usize = 16; +pub const REMOVED_VALIDATORS_ROOT: usize = 17; /// Number of used leaf slots in the top-level tree. -pub const NUM_TOP_LEAVES: usize = 17; +pub const NUM_TOP_LEAVES: usize = 18; // --- Validator field indices (within each validator's 8-leaf subtree) --- @@ -206,6 +207,11 @@ impl SszStateTree { .set_leaf(VALIDATOR_MAXIMUM_STAKE, stake.hash_tree_root()); } + pub fn set_allowed_timestamp_future_ms(&mut self, ms: u64) { + self.top + .set_leaf(ALLOWED_TIMESTAMP_FUTURE_MS, ms.hash_tree_root()); + } + pub fn set_next_withdrawal_index(&mut self, index: u64) { self.top .set_leaf(NEXT_WITHDRAWAL_INDEX, index.hash_tree_root()); @@ -749,6 +755,7 @@ impl SszStateTree { ProtocolParam::MinimumStake(v) => (0u64, *v), ProtocolParam::MaximumStake(v) => (1u64, *v), ProtocolParam::EpochLength(v) => (2u64, *v), + ProtocolParam::AllowedTimestampFuture(v) => (3u64, *v), }; tree.set_leaf(base + PROTOCOL_PARAM_FIELD_TAG, tag.hash_tree_root()); tree.set_leaf(base + PROTOCOL_PARAM_FIELD_VALUE, value.hash_tree_root()); @@ -844,6 +851,7 @@ impl SszStateTree { epoch_genesis_hash: &[u8; 32], validator_minimum_stake: u64, validator_maximum_stake: u64, + allowed_timestamp_future_ms: u64, next_withdrawal_index: u64, forkchoice_head: &[u8; 32], forkchoice_safe: &[u8; 32], @@ -865,6 +873,7 @@ impl SszStateTree { self.set_epoch_genesis_hash(epoch_genesis_hash); self.set_validator_minimum_stake(validator_minimum_stake); self.set_validator_maximum_stake(validator_maximum_stake); + self.set_allowed_timestamp_future_ms(allowed_timestamp_future_ms); self.set_next_withdrawal_index(next_withdrawal_index); self.set_forkchoice_head_block_hash(forkchoice_head); self.set_forkchoice_safe_block_hash(forkchoice_safe); @@ -1600,20 +1609,24 @@ mod tests { assert_ne!(tree.root(), r5); let r6 = tree.root(); - tree.set_next_withdrawal_index(7); + tree.set_allowed_timestamp_future_ms(5_000); assert_ne!(tree.root(), r6); let r7 = tree.root(); - tree.set_forkchoice_head_block_hash(&[3u8; 32]); + tree.set_next_withdrawal_index(7); assert_ne!(tree.root(), r7); let r8 = tree.root(); - tree.set_forkchoice_safe_block_hash(&[4u8; 32]); + tree.set_forkchoice_head_block_hash(&[3u8; 32]); assert_ne!(tree.root(), r8); let r9 = tree.root(); - tree.set_forkchoice_finalized_block_hash(&[5u8; 32]); + tree.set_forkchoice_safe_block_hash(&[4u8; 32]); assert_ne!(tree.root(), r9); + let r10 = tree.root(); + + tree.set_forkchoice_finalized_block_hash(&[5u8; 32]); + assert_ne!(tree.root(), r10); } #[test] @@ -1734,6 +1747,7 @@ mod tests { inc.set_epoch_genesis_hash(&[0xBB; 32]); inc.set_validator_minimum_stake(32_000_000_000); inc.set_validator_maximum_stake(64_000_000_000); + inc.set_allowed_timestamp_future_ms(10_000); inc.set_next_withdrawal_index(5); inc.set_forkchoice_head_block_hash(&[0xCC; 32]); inc.set_forkchoice_safe_block_hash(&[0xDD; 32]); @@ -1755,6 +1769,7 @@ mod tests { &[0xBB; 32], 32_000_000_000, 64_000_000_000, + 10_000, 5, &[0xCC; 32], &[0xDD; 32], @@ -1785,6 +1800,7 @@ mod tests { &[0u8; 32], 32_000_000_000, 32_000_000_000, + 10_000, 0, &[0u8; 32], &[0u8; 32], diff --git a/types/src/ssz_tree_key.rs b/types/src/ssz_tree_key.rs index 1b367bd2..d688da87 100644 --- a/types/src/ssz_tree_key.rs +++ b/types/src/ssz_tree_key.rs @@ -55,6 +55,9 @@ pub fn parse_key(descriptor: &str) -> Result { "validator_maximum_stake" => { Ok(SszStateKey::Scalar(ssz_state_tree::VALIDATOR_MAXIMUM_STAKE)) } + "allowed_timestamp_future_ms" => Ok(SszStateKey::Scalar( + ssz_state_tree::ALLOWED_TIMESTAMP_FUTURE_MS, + )), "next_withdrawal_index" => Ok(SszStateKey::Scalar(ssz_state_tree::NEXT_WITHDRAWAL_INDEX)), "forkchoice_head_block_hash" => Ok(SszStateKey::Scalar( ssz_state_tree::FORKCHOICE_HEAD_BLOCK_HASH,