diff --git a/Cargo.lock b/Cargo.lock index b16187d4ac..50dc26d63a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5167,10 +5167,15 @@ dependencies = [ "anyhow", "bytes", "ed25519-consensus", + "hex", + "prost", "rand_core", + "serde", + "serde_json", "sha2 0.10.8", "tap", "tendermint", + "tendermint-proto", "tower", "tracing", ] @@ -5179,6 +5184,7 @@ dependencies = [ name = "penumbra-mock-tendermint-proxy" version = "0.80.2" dependencies = [ + "hex", "pbjson-types", "penumbra-mock-consensus", "penumbra-proto", 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 b242c9c8eb..4678141941 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 @@ -61,7 +61,7 @@ async fn app_tracks_uptime_for_genesis_validator_missing_blocks() -> anyhow::Res let height = 4; for i in 1..=height { node.block() - .with_signatures(vec![]) + .without_signatures() .execute() .tap(|_| trace!(%i, "executing block with no signatures")) .instrument(error_span!("executing block with no signatures", %i)) diff --git a/crates/core/app/tests/common/test_node_builder_ext.rs b/crates/core/app/tests/common/test_node_builder_ext.rs index b074232cb5..93bf7f7fee 100644 --- a/crates/core/app/tests/common/test_node_builder_ext.rs +++ b/crates/core/app/tests/common/test_node_builder_ext.rs @@ -26,7 +26,7 @@ pub trait BuilderExt: Sized { impl BuilderExt for Builder { type Error = anyhow::Error; - fn with_penumbra_auto_app_state(self, app_state: AppState) -> Result { + fn with_penumbra_auto_app_state(mut self, app_state: AppState) -> Result { let Self { keyring, .. } = &self; let mut content = match app_state { AppState::Content(c) => c, @@ -34,9 +34,13 @@ impl BuilderExt for Builder { }; for (consensus_vk, _) in keyring { + // Let the seed for the penumbra validator be derived from the verification key, + // that way tests can operate with no rng. + let seed = Some(SpendKeyBytes(consensus_vk.to_bytes())); + // Generate a penumbra validator with this consensus key, and a corresponding // allocation of delegation tokens. - let (validator, allocation) = generate_penumbra_validator(consensus_vk); + let (validator, allocation) = generate_penumbra_validator(consensus_vk, seed); // Add the validator to the staking component's genesis content. trace!(?validator, "adding validator to staking genesis content"); @@ -50,6 +54,11 @@ impl BuilderExt for Builder { content.shielded_pool_content.allocations.push(allocation); } + // Set the chain ID from the content + if !content.chain_id.is_empty() { + self.chain_id = Some(content.chain_id.clone()); + } + // Serialize the app state into bytes, and add it to the builder. let app_state = AppState::Content(content); serde_json::to_vec(&app_state) @@ -61,8 +70,9 @@ impl BuilderExt for Builder { /// Generates a [`Validator`][PenumbraValidator] given a consensus verification key. fn generate_penumbra_validator( consensus_key: &ed25519_consensus::VerificationKey, + seed: Option, ) -> (PenumbraValidator, Allocation) { - let seed = SpendKeyBytes(OsRng.gen()); + let seed = seed.unwrap_or(SpendKeyBytes(OsRng.gen())); let spend_key = SpendKey::from(seed.clone()); let validator_id_sk = spend_key.spend_auth_key(); let validator_id_vk = VerificationKey::from(validator_id_sk); diff --git a/crates/test/mock-client/src/lib.rs b/crates/test/mock-client/src/lib.rs index ea7ecbe960..a46a29e86c 100644 --- a/crates/test/mock-client/src/lib.rs +++ b/crates/test/mock-client/src/lib.rs @@ -42,6 +42,16 @@ impl MockClient { Ok(self) } + pub async fn with_sync_to_inner_storage( + mut self, + storage: cnidarium::Storage, + ) -> anyhow::Result { + let latest = storage.latest_snapshot(); + self.sync_to_latest(latest).await?; + + Ok(self) + } + pub async fn sync_to_latest(&mut self, state: R) -> anyhow::Result<()> { let height = state.get_block_height().await?; self.sync_to(height, state).await?; diff --git a/crates/test/mock-consensus/Cargo.toml b/crates/test/mock-consensus/Cargo.toml index 8c75ced395..9b1ba0b45c 100644 --- a/crates/test/mock-consensus/Cargo.toml +++ b/crates/test/mock-consensus/Cargo.toml @@ -15,9 +15,14 @@ license.workspace = true anyhow = { workspace = true } bytes = { workspace = true } ed25519-consensus = { workspace = true } +hex = { workspace = true } +prost = { workspace = true } rand_core = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } sha2 = { workspace = true } tap = { workspace = true } tendermint = { workspace = true, default-features = true } +tendermint-proto = { workspace = true } tower = { workspace = true, features = ["full"] } tracing = { workspace = true } diff --git a/crates/test/mock-consensus/src/abci.rs b/crates/test/mock-consensus/src/abci.rs index fd5c67f6bb..dbea3c1a20 100644 --- a/crates/test/mock-consensus/src/abci.rs +++ b/crates/test/mock-consensus/src/abci.rs @@ -160,6 +160,7 @@ where retain_height, } = &response; trace!(?data, ?retain_height, "received Commit response"); + Ok(response) } response => { diff --git a/crates/test/mock-consensus/src/block.rs b/crates/test/mock-consensus/src/block.rs index af75efeda0..144a739835 100644 --- a/crates/test/mock-consensus/src/block.rs +++ b/crates/test/mock-consensus/src/block.rs @@ -4,13 +4,16 @@ use { crate::TestNode, + prost::Message, + sha2::{Digest, Sha256}, tap::Tap, tendermint::{ account, block::{self, header::Version, Block, Commit, Header, Round}, - chain, evidence, + evidence, + merkle::simple_hash_from_byte_vectors, v0_37::abci::{ConsensusRequest, ConsensusResponse}, - AppHash, Hash, Time, + Hash, Time, }, tower::{BoxError, Service}, tracing::{instrument, trace}, @@ -34,10 +37,10 @@ pub struct Builder<'e, C> { data: Vec>, /// Evidence of malfeasance. evidence: evidence::List, - /// The list of signatures. - signatures: Vec, /// The timestamp of the block. timestamp: Time, + /// Disable producing signatures. Defaults to produce signatures. + disable_signatures: bool, } // === impl TestNode === @@ -45,19 +48,14 @@ pub struct Builder<'e, C> { impl TestNode { /// Returns a new [`Builder`]. /// - /// By default, signatures for all of the validators currently within the keyring will be - /// included in the block. Use [`Builder::with_signatures()`] to set a different set of - /// validator signatures. pub fn block(&mut self) -> Builder<'_, C> { let ts = self.timestamp.clone(); - let signatures = self.generate_signatures().collect(); - // set default TS hook Builder { test_node: self, data: Default::default(), evidence: Default::default(), - signatures, timestamp: ts, + disable_signatures: false, } } } @@ -90,9 +88,12 @@ impl<'e, C> Builder<'e, C> { Self { evidence, ..self } } - /// Sets the [`CommitSig`][block::CommitSig] commit signatures for this block. - pub fn with_signatures(self, signatures: Vec) -> Self { - Self { signatures, ..self } + /// Disables producing commit signatures for this block. + pub fn without_signatures(self) -> Self { + Self { + disable_signatures: true, + ..self + } } } @@ -108,14 +109,22 @@ where /// Consumes this builder, executing the [`Block`] using the consensus service. /// /// Use [`TestNode::block()`] to build a new block. + /// + /// By default, signatures for all of the validators currently within the keyring will be + /// included in the block. Use [`Builder::without_signatures()`] to disable producing + /// validator signatures. #[instrument(level = "info", skip_all, fields(height, time))] pub async fn execute(self) -> Result<(), anyhow::Error> { + // Calling `finish` finishes the previous block + // and prepares the current block. let (test_node, block) = self.finish()?; let Block { + // The header for the current block header, data, evidence: _, + // Votes for the previous block last_commit, .. } = block.clone().tap(|block| { @@ -125,6 +134,7 @@ where }); let last_commit_info = Self::last_commit_info(last_commit); + let height = header.height; trace!("sending block"); test_node.begin_block(header, last_commit_info).await?; for tx in data { @@ -132,15 +142,26 @@ where test_node.deliver_tx(tx).await?; } test_node.end_block().await?; - test_node.commit().await?; - trace!("finished sending block"); + + // the commit call will set test_node.last_app_hash, preparing + // for the next block to begin execution + let commit_response = test_node.commit().await?; + + // NOTE: after calling .commit(), the internal status of the pd node's storage is going to be updated + // to the next block + // therefore we need to update the height within our mock now now + + // Set the last app hash to the new block's app hash. + test_node.last_app_hash = commit_response.data.to_vec(); + test_node.height = height; + trace!( + last_app_hash = ?hex::encode(commit_response.data.to_vec()), + "test node has committed block, setting last_app_hash" + ); // If an `on_block` callback was set, call it now. test_node.on_block.as_mut().map(move |f| f(block)); - // Call the timestamp callback to increment the node's current timestamp. - test_node.timestamp = (test_node.ts_callback)(test_node.timestamp.clone()); - Ok(()) } @@ -156,52 +177,161 @@ where data, evidence, test_node, - signatures, timestamp, + disable_signatures, } = self; + // Call the timestamp callback to increment the node's current timestamp. + test_node.timestamp = (test_node.ts_callback)(test_node.timestamp.clone()); + + // The first (non-genesis) block has height 1. let height = { let height = test_node.height.increment(); - test_node.height = height; tracing::Span::current().record("height", height.value()); height }; - let last_commit = if height.value() != 1 { - let block_id = block::Id { - hash: Hash::None, - part_set_header: block::parts::Header::new(0, Hash::None)?, - }; - Some(Commit { - height, - round: Round::default(), - block_id, - signatures, - }) - } else { - None // The first block has no previous commit to speak of. - }; + let last_commit = &test_node.last_commit; + + // Set the validator set based on the current configuration. + let pk = test_node + .keyring + .iter() + .next() + .expect("validator key in keyring") + .0; + let proposer_address = account::Id::new( + ::digest(pk).as_slice()[0..20] + .try_into() + .expect(""), + ); + + let validators_hash = test_node.last_validator_set_hash.clone().unwrap(); + // The data hash is the sha256 hash of all the transactions + // I think as long as we are consistent here it's fine. + let data_hash = sha2::Sha256::digest(&data.concat()).to_vec(); + let consensus_hash = test_node.consensus_params_hash.clone().try_into().unwrap(); let header = Header { - version: Version { block: 1, app: 1 }, - chain_id: chain::Id::try_from("test".to_owned())?, + // Protocol version. Block version 11 matches cometbft when tests were written. + version: Version { block: 11, app: 0 }, + chain_id: tendermint::chain::Id::try_from(test_node.chain_id.clone())?, + // Height is the height for this header. height, time: timestamp, - last_block_id: None, - last_commit_hash: None, - data_hash: None, - validators_hash: Hash::None, - next_validators_hash: Hash::None, - consensus_hash: Hash::None, - app_hash: AppHash::try_from(Vec::default())?, - last_results_hash: None, - evidence_hash: None, - proposer_address: account::Id::new([ - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - ]), + // MerkleRoot of the lastCommit’s signatures. The signatures represent the validators that committed to the last block. The first block has an empty slices of bytes for the hash. + last_commit_hash: test_node + .last_commit + .as_ref() + .map(|c| c.hash().unwrap()) + .unwrap_or(Some( + // empty hash value + hex::decode( + "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855", + )? + .try_into()?, + )), + last_block_id: test_node.last_commit.as_ref().map(|c| c.block_id.clone()), + // MerkleRoot of the hash of transactions. Note: The transactions are hashed before being included in the merkle tree, the leaves of the Merkle tree are the hashes, not the transactions themselves. + data_hash: Some(tendermint::Hash::Sha256(data_hash.try_into().unwrap())), + // force the header to have the hash of the validator set to pass + // the validation + // MerkleRoot of the current validator set + validators_hash: validators_hash.into(), + // MerkleRoot of the next validator set + next_validators_hash: validators_hash.into(), + // Hash of the protobuf encoded consensus parameters. + consensus_hash, + // Arbitrary byte array returned by the application after executing and committing the previous block. + app_hash: tendermint::AppHash::try_from(test_node.last_app_hash().to_vec())?, + // TODO: we should probably have a way to set this + // root hash of a Merkle tree built from DeliverTxResponse responses(Log,Info, Codespace and Events fields are ignored).The first block has block.Header.ResultsHash == MerkleRoot(nil), i.e. the hash of an empty input, for RFC-6962 conformance. + // the go version will shasum empty bytes and produce "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855" + last_results_hash: Some( + hex::decode("E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855")? + .try_into()?, + ), + // MerkleRoot of the evidence of Byzantine behavior included in this block. + evidence_hash: Some( + hex::decode("E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855")? + .try_into()?, + ), + // Address of the original proposer of the block. Validator must be in the current validatorSet. + proposer_address, }; - let block = Block::new(header, data, evidence, last_commit)?; + tracing::trace!(?header, "built block header"); + + // The next block will use the signatures of this block's header. + let signatures: Vec = if !disable_signatures { + test_node.generate_signatures(&header).collect() + } else { + vec![] + }; + + tracing::trace!( + height=?height.value(), + app_hash=?hex::encode(header.app_hash.clone()), + block_id=?hex::encode(header.hash()), + last_commit_height=?last_commit.as_ref().map(|c| c.height.value()), + "made block" + ); + let block = Block::new(header.clone(), data, evidence, last_commit.clone())?; + + // Now that the block is finalized, we can transition to the next block. + // Generate a commit for the header we just made, that will be + // included in the next header. + test_node.last_commit = Some(Commit { + height: block.header.height, + round: Round::default(), + block_id: block::Id { + hash: block.header.hash().into(), + // The part_set_header seems to be used internally by cometbft + // and the pd node doesn't care about it + part_set_header: block::parts::Header::new(0, Hash::None)?, + }, + // Signatures of the last block + signatures: signatures.clone(), + }); Ok((test_node, block)) } } + +// Allows hashing of commits +pub trait CommitHashingExt: Sized { + fn hash(&self) -> anyhow::Result>; +} + +impl CommitHashingExt for Commit { + // https://github.com/tendermint/tendermint/blob/51dc810d041eaac78320adc6d53ad8b160b06601/types/block.go#L672 + fn hash(&self) -> anyhow::Result> { + // make a vec of the precommit protobuf encodings + // then merkle hash them + // https://github.com/tendermint/tendermint/blob/35581cf54ec436b8c37fabb43fdaa3f48339a170/crypto/merkle/tree.go#L9 + let bs = self + .signatures + .iter() + .map(|precommit| { + tendermint_proto::types::CommitSig::from(precommit.clone()).encode_to_vec() + }) + .collect::>(); + + match bs.len() { + 0 => + // empty hash + { + Ok(Some( + hex::decode( + "E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855", + )? + .try_into()?, + )) + } + _ => Ok(Some( + simple_hash_from_byte_vectors::(&bs) + .to_vec() + .try_into()?, + )), + } + } +} diff --git a/crates/test/mock-consensus/src/block/signature.rs b/crates/test/mock-consensus/src/block/signature.rs index f3195a57be..15ce62589a 100644 --- a/crates/test/mock-consensus/src/block/signature.rs +++ b/crates/test/mock-consensus/src/block/signature.rs @@ -17,11 +17,33 @@ mod sign { /// Returns a [commit signature] saying this validator voted for the block. /// /// [commit signature]: CommitSig - pub(super) fn commit(validator_address: Id, timestamp: Time) -> CommitSig { + pub(super) fn commit( + validator_address: Id, + validator_key: &ed25519_consensus::SigningKey, + canonical: &tendermint::vote::CanonicalVote, + ) -> CommitSig { + // Create a vote to be signed + // https://github.com/informalsystems/tendermint-rs/blob/14fd628e82ae51b9f15c135a6db8870219fe3c33/testgen/src/commit.rs#L214 + // https://github.com/informalsystems/tendermint-rs/blob/14fd628e82ae51b9f15c135a6db8870219fe3c33/testgen/src/commit.rs#L104 + + use tendermint_proto::v0_37::types::CanonicalVote as RawCanonicalVote; + let sign_bytes = + tendermint_proto::Protobuf::::encode_length_delimited_vec( + canonical.clone(), + ); + + let signature: tendermint::Signature = validator_key + .sign(sign_bytes.as_slice()) + .try_into() + .unwrap(); + + // encode to stable-json deterministic JSON wire encoding, + // https://github.com/informalsystems/tendermint-rs/blob/14fd628e82ae51b9f15c135a6db8870219fe3c33/testgen/src/helpers.rs#L43C1-L44C1 + CommitSig::BlockIdFlagCommit { validator_address, - timestamp, - signature: None, + timestamp: canonical.timestamp.expect("timestamp should be present"), + signature: Some(signature.into()), } } @@ -46,18 +68,42 @@ impl TestNode { // 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 - .keys() - .map(|vk| { + /// Signatures sign the given block header. + pub(super) fn generate_signatures( + &self, + header: &tendermint::block::Header, + ) -> impl Iterator + '_ { + let block_id = tendermint::block::Id { + hash: header.hash(), + part_set_header: tendermint::block::parts::Header::new(0, tendermint::Hash::None) + .unwrap(), + }; + let canonical = tendermint::vote::CanonicalVote { + // The mock consensus engine ONLY has precommit votes right now + vote_type: tendermint::vote::Type::Precommit, + height: tendermint::block::Height::from(header.height), + // round is always 0 + round: 0u8.into(), + block_id: Some(block_id), + // Block header time is used throughout + timestamp: Some(header.time.clone()), + // timestamp: Some(last_commit_info.timestamp), + chain_id: self.chain_id.clone(), + }; + tracing::trace!(vote=?canonical,"canonical vote constructed"); + + return self + .keyring + .iter() + .map(|(vk, sk)| { ( ::digest(vk).as_slice()[0..20] .try_into() .expect(""), - self.timestamp.clone(), + sk, ) }) - .map(|(a, b)| (self::sign::commit(account::Id::new(a), b))) + .map(move |(id, sk)| self::sign::commit(account::Id::new(id), sk, &canonical)); } } diff --git a/crates/test/mock-consensus/src/builder.rs b/crates/test/mock-consensus/src/builder.rs index 8afd9fedef..6718028b56 100644 --- a/crates/test/mock-consensus/src/builder.rs +++ b/crates/test/mock-consensus/src/builder.rs @@ -7,9 +7,11 @@ mod init_chain; use { crate::{Keyring, OnBlockFn, TestNode, TsCallbackFn}, + anyhow::Result, bytes::Bytes, + ed25519_consensus::{SigningKey, VerificationKey}, std::time::Duration, - tendermint::Time, + tendermint::{Genesis, Time}, }; // Default timestamp callback will increment the time by 5 seconds. @@ -26,6 +28,17 @@ pub struct Builder { pub on_block: Option, pub ts_callback: Option, pub initial_timestamp: Option