diff --git a/Cargo.lock b/Cargo.lock index b1a6cf43f6..4e9396149c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4714,6 +4714,7 @@ dependencies = [ "penumbra-transaction", "penumbra-txhash", "prost", + "rand 0.8.5", "rand_chacha 0.3.1", "rand_core 0.6.4", "regex", @@ -5272,7 +5273,8 @@ version = "0.68.0" dependencies = [ "anyhow", "bytes", - "serde_json", + "ed25519-consensus", + "rand_core 0.6.4", "tap", "tendermint", "tower", diff --git a/crates/core/app/Cargo.toml b/crates/core/app/Cargo.toml index 911d5a1476..b1379a44be 100644 --- a/crates/core/app/Cargo.toml +++ b/crates/core/app/Cargo.toml @@ -81,6 +81,7 @@ tracing = {workspace = true} ed25519-consensus = {workspace = true} penumbra-mock-consensus = {workspace = true} penumbra-mock-client = {workspace = true} +rand = {workspace = true} rand_core = {workspace = true} rand_chacha = {workspace = true} tap = {workspace = true} diff --git a/crates/core/app/src/app/mod.rs b/crates/core/app/src/app/mod.rs index 0cb25e920d..668fe58206 100644 --- a/crates/core/app/src/app/mod.rs +++ b/crates/core/app/src/app/mod.rs @@ -56,6 +56,7 @@ pub struct App { } impl App { + /// Constructs a new application, using the provided [`Snapshot`]. pub async fn new(snapshot: Snapshot) -> Result { tracing::debug!("initializing App instance"); diff --git a/crates/core/app/tests/common/mod.rs b/crates/core/app/tests/common/mod.rs index f400c349fb..f47e16d436 100644 --- a/crates/core/app/tests/common/mod.rs +++ b/crates/core/app/tests/common/mod.rs @@ -3,15 +3,24 @@ // NB: Allow dead code, these are in fact shared by files in `tests/`. #![allow(dead_code)] -use async_trait::async_trait; -use cnidarium::TempStorage; -use penumbra_app::{ - app::App, - server::consensus::{Consensus, ConsensusService}, +pub use self::test_node_builder_ext::BuilderExt; + +use { + async_trait::async_trait, + cnidarium::TempStorage, + penumbra_app::{ + app::App, + server::consensus::{Consensus, ConsensusService}, + }, + penumbra_genesis::AppState, + penumbra_mock_consensus::TestNode, + std::ops::Deref, }; -use penumbra_genesis::AppState; -use penumbra_mock_consensus::TestNode; -use std::ops::Deref; + +/// Penumbra-specific extensions to the mock consensus builder. +/// +/// See [`BuilderExt`]. +mod test_node_builder_ext; // Installs a tracing subscriber to log events until the returned guard is dropped. pub fn set_tracing_subscriber() -> tracing::subscriber::DefaultGuard { @@ -80,22 +89,3 @@ impl TempStorageExt for TempStorage { self.apply_genesis(Default::default()).await } } - -/// Penumbra-specific extensions to the mock consensus builder. -pub trait BuilderExt: Sized { - type Error; - fn with_penumbra_auto_app_state(self, app_state: AppState) -> Result; -} - -impl BuilderExt for penumbra_mock_consensus::builder::Builder { - type Error = anyhow::Error; - fn with_penumbra_auto_app_state(self, app_state: AppState) -> Result { - // what to do here? - // - read out list of abci/comet validators from the builder, - // - define a penumbra validator for each one - // - inject that into the penumbra app state - // - serialize to json and then call `with_app_state_bytes` - let app_state = serde_json::to_vec(&app_state)?; - Ok(self.app_state(app_state)) - } -} diff --git a/crates/core/app/tests/common/test_node_builder_ext.rs b/crates/core/app/tests/common/test_node_builder_ext.rs new file mode 100644 index 0000000000..b527c37928 --- /dev/null +++ b/crates/core/app/tests/common/test_node_builder_ext.rs @@ -0,0 +1,118 @@ +use { + penumbra_genesis::AppState, + penumbra_mock_consensus::builder::Builder, + penumbra_proto::{ + core::keys::v1::{GovernanceKey, IdentityKey}, + penumbra::core::component::stake::v1::Validator as PenumbraValidator, + }, +}; + +/// Penumbra-specific extensions to the mock consensus builder. +pub trait BuilderExt: Sized { + /// The error thrown by [`with_penumbra_auto_app_state`] + type Error; + /// Add the provided Penumbra [`AppState`] to the builder. + /// + /// This will inject any configured validators into the state before serializing it into bytes. + fn with_penumbra_auto_app_state(self, app_state: AppState) -> Result; +} + +impl BuilderExt for Builder { + type Error = anyhow::Error; + fn with_penumbra_auto_app_state(self, app_state: AppState) -> Result { + let Self { keyring, .. } = &self; + + let app_state = if keyring.is_empty() { + // If there are no consensus keys to inject, pass along the provided app state. + app_state + } else { + // Otherwise, generate a penumbra validator for each entry in the keyring... + let validators = keyring + .verification_keys() + .cloned() + .map(generate_penumbra_validator) + .inspect(log_validator) + .collect::>(); + // ...and then inject these validators into the app state. + inject_penumbra_validators(app_state, validators)? + }; + + // Serialize the app state into bytes, and add it to the builder. + serde_json::to_vec(&app_state) + .map_err(Self::Error::from) + .map(|s| self.app_state(s)) + } +} + +/// Injects the given collection of [`Validator`s][PenumbraValidator] into the app state. +fn inject_penumbra_validators( + app_state: AppState, + validators: Vec, +) -> Result { + use AppState::{Checkpoint, Content}; + match app_state { + Checkpoint(_) => anyhow::bail!("checkpoint app state isn't supported"), + Content(mut content) => { + // Inject the builder's validators into the staking component's genesis state... + let overwritten = std::mem::replace(&mut content.stake_content.validators, validators); + // ...and log a warning if this overwrote any validators already in the app state. + if !overwritten.is_empty() { + tracing::warn!( + ?overwritten, + "`with_penumbra_auto_app_state` overwrote validators in the given AppState" + ) + } + Ok(Content(content)) + } + } +} + +/// Generates a [`Validator`][PenumbraValidator] given a consensus verification key. +fn generate_penumbra_validator( + consensus_key: ed25519_consensus::VerificationKey, +) -> PenumbraValidator { + /// A temporary stub for validator keys. + /// + /// An invalid key is intentionally provided here, until we have test coverage exercising the + /// use of these keys. Once we need it we will: + /// - generate a random signing key + /// - get its verification key + /// - use that for the identity key + /// - throw the signing key away + /// + /// NB: for now, we will use the same key for governance. See the documentation of + /// `GovernanceKey` for more information about cold storage of validator keys. + const INVALID_KEY_BYTES: [u8; 32] = [0; 32]; + + PenumbraValidator { + identity_key: Some(IdentityKey { + ik: INVALID_KEY_BYTES.to_vec().clone(), + }), + governance_key: Some(GovernanceKey { + gk: INVALID_KEY_BYTES.to_vec().clone(), + }), + consensus_key: consensus_key.as_bytes().to_vec(), + enabled: true, + sequence_number: 0, + name: String::default(), + website: String::default(), + description: String::default(), + funding_streams: Vec::default(), + } +} + +fn log_validator( + PenumbraValidator { + name, + enabled, + sequence_number, + .. + }: &PenumbraValidator, +) { + tracing::trace!( + %name, + %enabled, + %sequence_number, + "injecting validator into app state" + ) +} diff --git a/crates/core/app/tests/mock_consensus.rs b/crates/core/app/tests/mock_consensus.rs index c68b8e3397..5a2c3490b2 100644 --- a/crates/core/app/tests/mock_consensus.rs +++ b/crates/core/app/tests/mock_consensus.rs @@ -14,6 +14,7 @@ use { penumbra_proto::DomainType, penumbra_sct::component::{clock::EpochRead, tree::SctRead as _}, penumbra_shielded_pool::{OutputPlan, SpendPlan}, + penumbra_stake::component::validator_handler::ValidatorDataRead as _, penumbra_transaction::{ memo::MemoPlaintext, plan::MemoPlan, TransactionParameters, TransactionPlan, }, @@ -37,6 +38,42 @@ async fn mock_consensus_can_send_an_init_chain_request() -> anyhow::Result<()> { Ok(()) } +/// Exercises that the mock consensus engine can provide a single genesis validator. +#[tokio::test] +async fn mock_consensus_can_define_a_genesis_validator() -> 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?; + let _test_node = common::start_test_node(&storage).await?; + + let snapshot = storage.latest_snapshot(); + let validators = snapshot + .validator_definitions() + .tap(|_| info!("getting validator definitions")) + .await?; + match validators.as_slice() { + [v] => { + let identity_key = v.identity_key; + let status = snapshot + .get_validator_state(&identity_key) + .await? + .ok_or_else(|| anyhow!("could not find validator status"))?; + assert_eq!( + status, + penumbra_stake::validator::State::Active, + "validator should be active" + ); + } + unexpected => panic!("there should be one validator, got: {unexpected:?}"), + } + + // Free our temporary storage. + drop(storage); + drop(guard); + + Ok(()) +} + /// Exercises that a series of empty blocks, with no validator set present, can be successfully /// executed by the consensus service. #[tokio::test] diff --git a/crates/test/mock-consensus/Cargo.toml b/crates/test/mock-consensus/Cargo.toml index 5bbdd37521..4cec69ae7e 100644 --- a/crates/test/mock-consensus/Cargo.toml +++ b/crates/test/mock-consensus/Cargo.toml @@ -7,10 +7,15 @@ repository.workspace = true homepage.workspace = true license.workspace = true +# NB: to avoid circular dependencies: do not add other `penumbra-*` crates +# as dependencies, below. we should provide interfaces in terms of generic +# types, and provide penumbra-specific helper functions via extension traits. + [dependencies] anyhow = { workspace = true } bytes = { workspace = true } -serde_json = { workspace = true } +ed25519-consensus = { workspace = true } +rand_core = { workspace = true } tap = { workspace = true } tendermint = { workspace = true } tower = { workspace = true, features = ["full"] } diff --git a/crates/test/mock-consensus/src/builder.rs b/crates/test/mock-consensus/src/builder.rs index 7d90e11c1f..eeb444d3ee 100644 --- a/crates/test/mock-consensus/src/builder.rs +++ b/crates/test/mock-consensus/src/builder.rs @@ -5,12 +5,16 @@ /// Most importantly, defines [`Builder::init_chain()`]. mod init_chain; -use {crate::TestNode, bytes::Bytes}; +use { + crate::{keyring::Keyring, TestNode}, + bytes::Bytes, +}; /// A buider, used to prepare and instantiate a new [`TestNode`]. #[derive(Default)] pub struct Builder { - app_state: Option, + pub app_state: Option, + pub keyring: Keyring, } impl TestNode<()> { @@ -21,17 +25,17 @@ impl TestNode<()> { } impl Builder { - // TODO: add other convenience methods for validator config? - - /// Creates a single validator with a randomly generated key. - pub fn single_validator(self) -> Self { - // this does not do anything yet - self - } - /// Sets the `app_state_bytes` to send the ABCI application upon chain initialization. pub fn app_state(self, app_state: impl Into) -> Self { let app_state = Some(app_state.into()); Self { app_state, ..self } } + + /// Generates a single set of validator keys. + pub fn single_validator(self) -> Self { + Self { + keyring: Keyring::new_with_size(1), + ..self + } + } } diff --git a/crates/test/mock-consensus/src/builder/init_chain.rs b/crates/test/mock-consensus/src/builder/init_chain.rs index b60a7793fd..1a9c280852 100644 --- a/crates/test/mock-consensus/src/builder/init_chain.rs +++ b/crates/test/mock-consensus/src/builder/init_chain.rs @@ -32,6 +32,7 @@ impl Builder { let Self { app_state: Some(app_state), + keyring: _, } = self else { bail!("builder was not fully initialized") diff --git a/crates/test/mock-consensus/src/keyring.rs b/crates/test/mock-consensus/src/keyring.rs new file mode 100644 index 0000000000..9839c1d613 --- /dev/null +++ b/crates/test/mock-consensus/src/keyring.rs @@ -0,0 +1,105 @@ +//! Provides a [`Keyring`] for managing consensus keys. + +use { + ed25519_consensus::{SigningKey, VerificationKey}, + rand_core::{CryptoRng, OsRng, RngCore}, + std::collections::btree_map::{self, BTreeMap}, +}; + +/// A keyring of [`VerificationKey`] and [`SigningKey`] consensus keys. +#[derive(Clone, Debug, Default)] +pub struct Keyring(BTreeMap); + +/// An entry in a [`Keyring`]. +pub type Entry = (VerificationKey, SigningKey); + +impl Keyring { + /// Creates a new [`Keyring`]. + pub fn new() -> Self { + Self::default() + } + + /// Creates a new [`Keyring`] and fills with with `n` random entries. + pub fn new_with_size(n: usize) -> Self { + let gen = || Self::generate_key(OsRng); + std::iter::repeat_with(gen).take(n).collect() + } + + /// Returns the consensus signing key corresponding to the given verification key. + pub fn get(&self, verification_key: &VerificationKey) -> Option<&SigningKey> { + self.0.get(verification_key) + } + + /// Returns `true` if the keyring contains no elements. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Gets an iterator over the consensus verification keys in the keyring. + pub fn verification_keys(&self) -> impl Iterator { + self.0.keys() + } + + /// Gets an iterator over the consensus signing keys in the keyring. + pub fn signing_keys(&self) -> impl Iterator { + self.0.values() + } + + /// Generates a new key using the default [`OsRng`], and inserts it into the keyring. + /// + /// This returns the verification key associated with this new entry. + pub fn add_key(&mut self) -> VerificationKey { + self.add_key_with(OsRng) + } + + /// Generates a new key with the provided CSPRNG, and inserts it into the keyring. + /// + /// This returns the verification key associated with this new entry. + pub fn add_key_with(&mut self, rng: R) -> VerificationKey + where + R: RngCore + CryptoRng, + { + let (vk, sk) = Self::generate_key(rng); + self.0.insert(vk, sk); + vk + } + + /// Generates a new consensus key. + pub fn generate_key(rng: R) -> Entry + where + R: RngCore + CryptoRng, + { + let sk = ed25519_consensus::SigningKey::new(rng); + let vk = sk.verification_key(); + tracing::trace!(verification_key = ?vk, "generated consensus key"); + (vk, sk) + } +} + +type KeyringIter<'a> = btree_map::Iter<'a, VerificationKey, SigningKey>; +impl<'a> IntoIterator for &'a Keyring { + type Item = (&'a VerificationKey, &'a SigningKey); + type IntoIter = KeyringIter<'a>; + fn into_iter(self) -> KeyringIter<'a> { + self.0.iter() + } +} + +type KeyringIntoIter = btree_map::IntoIter; +impl IntoIterator for Keyring { + type Item = (VerificationKey, SigningKey); + type IntoIter = KeyringIntoIter; + fn into_iter(self) -> KeyringIntoIter { + self.0.into_iter() + } +} + +impl FromIterator for Keyring { + fn from_iter(iter: I) -> Keyring + where + I: IntoIterator, + { + let k = iter.into_iter().collect(); + Self(k) + } +} diff --git a/crates/test/mock-consensus/src/lib.rs b/crates/test/mock-consensus/src/lib.rs index 1bc9750200..9ef65dcb2f 100644 --- a/crates/test/mock-consensus/src/lib.rs +++ b/crates/test/mock-consensus/src/lib.rs @@ -6,6 +6,7 @@ pub mod block; pub mod builder; +pub mod keyring; mod abci;