diff --git a/Cargo.lock b/Cargo.lock index 47f8156a7e..be1b12c5b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5282,6 +5282,7 @@ dependencies = [ "bytes", "ed25519-consensus", "rand_core 0.6.4", + "sha2 0.10.8", "tap", "tendermint", "tower", diff --git a/crates/core/app/tests/app_tracks_uptime_for_genesis_validator_missing_blocks.rs b/crates/core/app/tests/app_tracks_uptime_for_genesis_validator_missing_blocks.rs index 285687d7c7..711e79ea17 100644 --- a/crates/core/app/tests/app_tracks_uptime_for_genesis_validator_missing_blocks.rs +++ b/crates/core/app/tests/app_tracks_uptime_for_genesis_validator_missing_blocks.rs @@ -11,7 +11,7 @@ use { component::validator_handler::validator_store::ValidatorDataRead, validator::Validator, }, tap::Tap, - tracing::{error_span, info, Instrument}, + tracing::{error_span, info, trace, Instrument}, }; #[tokio::test] @@ -60,10 +60,15 @@ async fn app_tracks_uptime_for_genesis_validator_missing_blocks() -> anyhow::Res // Jump ahead a few blocks. let height = 4; - node.fast_forward(height) - .instrument(error_span!("fast forwarding test node {height} blocks")) - .await - .context("fast forwarding {height} blocks")?; + for i in 1..=height { + node.block() + .with_signatures(Default::default()) + .execute() + .tap(|_| trace!(%i, "executing block with no signatures")) + .instrument(error_span!("executing block with no signatures", %i)) + .await + .context("executing block with no signatures")?; + } // Check the validator's uptime once more. We should have uptime data up to the fourth block, // and the validator should have missed all of the blocks between genesis and now. diff --git a/crates/core/app/tests/app_tracks_uptime_for_genesis_validator_signing_blocks.rs b/crates/core/app/tests/app_tracks_uptime_for_genesis_validator_signing_blocks.rs new file mode 100644 index 0000000000..da23d60847 --- /dev/null +++ b/crates/core/app/tests/app_tracks_uptime_for_genesis_validator_signing_blocks.rs @@ -0,0 +1,78 @@ +mod common; + +use { + self::common::BuilderExt, + anyhow::Context, + cnidarium::TempStorage, + penumbra_app::server::consensus::Consensus, + penumbra_genesis::AppState, + penumbra_mock_consensus::TestNode, + penumbra_stake::{ + component::validator_handler::validator_store::ValidatorDataRead, validator::Validator, + }, + tap::Tap, + tracing::{error_span, info, Instrument}, +}; + +#[tokio::test] +async fn app_tracks_uptime_for_genesis_validator_missing_blocks() -> anyhow::Result<()> { + // Install a test logger, acquire some temporary storage, and start the test node. + let guard = common::set_tracing_subscriber(); + let storage = TempStorage::new().await?; + + // Start the test node. + let mut node = { + let app_state = AppState::default(); + let consensus = Consensus::new(storage.as_ref().clone()); + TestNode::builder() + .single_validator() + .with_penumbra_auto_app_state(app_state)? + .init_chain(consensus) + .await + }?; + + // Retrieve the validator definition from the latest snapshot. + let Validator { identity_key, .. } = match storage + .latest_snapshot() + .validator_definitions() + .tap(|_| info!("getting validator definitions")) + .await? + .as_slice() + { + [v] => v.clone(), + unexpected => panic!("there should be one validator, got: {unexpected:?}"), + }; + let get_uptime = || async { + storage + .latest_snapshot() + .get_validator_uptime(&identity_key) + .await + .expect("should be able to get a validator uptime") + .expect("validator uptime should exist") + }; + + // Jump ahead a few blocks. + // TODO TODO TODO have the validator sign blocks here. + let height = 4; + node.fast_forward(height) + .instrument(error_span!("fast forwarding test node {height} blocks")) + .await + .context("fast forwarding {height} blocks")?; + + // Check the validator's uptime once more. We should have uptime data up to the fourth block, + // and the validator should have missed all of the blocks between genesis and now. + { + let uptime = get_uptime().await; + assert_eq!(uptime.as_of_height(), height); + assert_eq!( + uptime.num_missed_blocks(), + 0, + "validator should have signed the last {height} blocks" + ); + } + + Ok(()) + .tap(|_| drop(node)) + .tap(|_| drop(storage)) + .tap(|_| drop(guard)) +} diff --git a/crates/core/app/tests/app_tracks_uptime_for_validators_only_once_active.rs b/crates/core/app/tests/app_tracks_uptime_for_validators_only_once_active.rs index 839aaaa431..39656ddbff 100644 --- a/crates/core/app/tests/app_tracks_uptime_for_validators_only_once_active.rs +++ b/crates/core/app/tests/app_tracks_uptime_for_validators_only_once_active.rs @@ -287,8 +287,8 @@ async fn app_tracks_uptime_for_validators_only_once_active() -> anyhow::Result<( ); assert_eq!( existing.num_missed_blocks(), - (EPOCH_DURATION - 1) as usize, - "genesis validator has missed all blocks in the previous epoch" + 0, + "genesis validator has signed all blocks in the previous epoch" ); } diff --git a/crates/test/mock-consensus/Cargo.toml b/crates/test/mock-consensus/Cargo.toml index 4cec69ae7e..9a844fad02 100644 --- a/crates/test/mock-consensus/Cargo.toml +++ b/crates/test/mock-consensus/Cargo.toml @@ -16,6 +16,7 @@ anyhow = { workspace = true } bytes = { workspace = true } ed25519-consensus = { workspace = true } rand_core = { workspace = true } +sha2 = { workspace = true } tap = { workspace = true } tendermint = { workspace = true } tower = { workspace = true, features = ["full"] } diff --git a/crates/test/mock-consensus/src/abci.rs b/crates/test/mock-consensus/src/abci.rs index aa490fc604..fd5c67f6bb 100644 --- a/crates/test/mock-consensus/src/abci.rs +++ b/crates/test/mock-consensus/src/abci.rs @@ -7,7 +7,7 @@ use { tap::{Tap, TapFallible}, tendermint::{ abci::types::CommitInfo, - block::{Header, Round}, + block::Header, v0_37::abci::{request, response, ConsensusRequest, ConsensusResponse}, }, tower::{BoxError, Service}, @@ -41,14 +41,12 @@ where pub async fn begin_block( &mut self, header: Header, + last_commit_info: CommitInfo, ) -> Result { let request = ConsensusRequest::BeginBlock(request::BeginBlock { hash: tendermint::Hash::None, header, - last_commit_info: CommitInfo { - round: Round::from(1_u8), - votes: Default::default(), - }, + last_commit_info, byzantine_validators: Default::default(), }); let service = self.service().await?; diff --git a/crates/test/mock-consensus/src/block.rs b/crates/test/mock-consensus/src/block.rs index c013927060..34a1e02bc8 100644 --- a/crates/test/mock-consensus/src/block.rs +++ b/crates/test/mock-consensus/src/block.rs @@ -2,6 +2,9 @@ //! //! Builders are acquired by calling [`TestNode::block()`]. +/// Interfaces for generating commit signatures. +mod signature; + use { crate::TestNode, tap::Tap, @@ -28,15 +31,22 @@ pub struct Builder<'e, C> { /// Evidence of malfeasance. evidence: evidence::List, + + /// The list of signatures. + signatures: Vec, } +// === impl TestNode === + impl TestNode { /// Returns a new [`Builder`]. pub fn block<'e>(&'e mut self) -> Builder<'e, C> { + let signatures = self.generate_signatures().collect(); Builder { test_node: self, data: Default::default(), evidence: Default::default(), + signatures, } } } @@ -60,8 +70,10 @@ impl<'e, C> Builder<'e, C> { Self { evidence, ..self } } - // TODO(kate): add more `with_` setters for fields in the header. - // TODO(kate): set some fields using state in the test node. + /// Sets the [`CommitSig`][block::CommitSig] commit signatures for this block. + pub fn with_signatures(self, signatures: Vec) -> Self { + Self { signatures, ..self } + } } impl<'e, C> Builder<'e, C> @@ -84,16 +96,17 @@ where header, data, evidence: _, - last_commit: _, + last_commit, .. } = block.tap(|block| { tracing::span::Span::current() .record("height", block.header.height.value()) .record("time", block.header.time.unix_timestamp()); }); + let last_commit_info = Self::last_commit_info(last_commit); trace!("sending block"); - test_node.begin_block(header).await?; + test_node.begin_block(header, last_commit_info).await?; for tx in data { let tx = tx.into(); test_node.deliver_tx(tx).await?; @@ -117,6 +130,7 @@ where data, evidence, test_node, + signatures, } = self; let height = { @@ -135,7 +149,7 @@ where height, round: Round::default(), block_id, - signatures: Vec::default(), + signatures, }) } else { None // The first block has no previous commit to speak of. diff --git a/crates/test/mock-consensus/src/block/signature.rs b/crates/test/mock-consensus/src/block/signature.rs new file mode 100644 index 0000000000..3275ddc433 --- /dev/null +++ b/crates/test/mock-consensus/src/block/signature.rs @@ -0,0 +1,124 @@ +use { + super::Builder, + crate::TestNode, + sha2::{Digest, Sha256}, + tendermint::{ + abci::types::{BlockSignatureInfo, CommitInfo, VoteInfo}, + account, + block::{BlockIdFlag, Commit, CommitSig, Round}, + vote::Power, + }, +}; + +/// Helper functions for generating [commit signatures]. +mod sign { + use tendermint::{account::Id, block::CommitSig, time::Time}; + + /// Returns a [commit signature] saying this validator voted for the block. + /// + /// [commit signature]: CommitSig + pub(super) fn commit(validator_address: Id) -> CommitSig { + CommitSig::BlockIdFlagCommit { + validator_address, + timestamp: timestamp(), + signature: None, + } + } + + /// Returns a [commit signature] saying this validator voted nil. + /// + /// [commit signature]: CommitSig + #[allow(dead_code)] + pub(super) fn nil(validator_address: Id) -> CommitSig { + CommitSig::BlockIdFlagNil { + validator_address, + timestamp: timestamp(), + signature: None, + } + } + + /// Generates a new timestamp, marked at the current time. + // + // TODO(kate): see https://github.com/penumbra-zone/penumbra/issues/3759, re: timestamps. + // eventually, we will add hooks so that we can control these timestamps. + fn timestamp() -> Time { + Time::now() + } +} + +// === impl TestNode === + +impl TestNode { + // TODO(kate): other interfaces may be helpful to add in the future, and these may eventually + // warrant being made `pub`. we defer doing so for now, only defining what is needed to provide + // commit signatures from all of the validators. + + /// Returns an [`Iterator`] of signatures for validators in the keyring. + pub(super) fn generate_signatures(&self) -> impl Iterator + '_ { + self.keyring + .iter() + // Compute the address of this validator. + .map(|(vk, _)| -> [u8; 20] { + ::digest(vk).as_slice()[0..20] + .try_into() + .expect("") + }) + .map(account::Id::new) + .map(self::sign::commit) + } +} + +// === impl Builder === + +impl<'e, C: 'e> Builder<'e, C> { + /// Returns [`CommitInfo`] given a block's [`Commit`]. + pub(super) fn last_commit_info(last_commit: Option) -> CommitInfo { + let Some(Commit { + round, signatures, .. + }) = last_commit + else { + // If there is no commit information about the last block, return an empty object. + return CommitInfo { + round: Round::default(), + votes: Vec::default(), + }; + }; + + CommitInfo { + round, + votes: signatures + .into_iter() + .map(Self::vote) + .filter_map(|v| v) + .collect(), + } + } + + /// Returns a [`VoteInfo`] for this [`CommitSig`]. + /// + /// If no validator voted, returns [`None`]. + fn vote(commit_sig: CommitSig) -> Option { + use tendermint::abci::types::Validator; + + // TODO(kate): upstream this into the `tendermint` library. + let sig_info = BlockSignatureInfo::Flag(match commit_sig { + CommitSig::BlockIdFlagAbsent => BlockIdFlag::Absent, + CommitSig::BlockIdFlagCommit { .. } => BlockIdFlag::Commit, + CommitSig::BlockIdFlagNil { .. } => BlockIdFlag::Nil, + }); + + let address: [u8; 20] = commit_sig + .validator_address()? + // TODO(kate): upstream an accessor to retrieve this as the [u8; 20] that it is. + .as_bytes() + .try_into() + .expect("validator address should be 20 bytes"); + let power = Power::from(1_u8); // TODO(kate): for now, hard-code voting power to 1. + let validator = Validator { address, power }; + + Some(VoteInfo { + validator, + sig_info, + }) + } +}