diff --git a/crates/bin/pd/src/main.rs b/crates/bin/pd/src/main.rs index e5eff8b0b5..2fa080b80d 100644 --- a/crates/bin/pd/src/main.rs +++ b/crates/bin/pd/src/main.rs @@ -1,9 +1,6 @@ #![allow(clippy::clone_on_copy)] #![recursion_limit = "512"] -use std::{ - net::{Ipv4Addr, SocketAddr}, - path::PathBuf, -}; +use std::{net::SocketAddr, path::PathBuf}; use console_subscriber::ConsoleLayer; use metrics_tracing_context::{MetricsLayer, TracingContextLayer}; @@ -15,8 +12,9 @@ use futures::stream::TryStreamExt; use metrics_exporter_prometheus::PrometheusBuilder; use pd::events::EventIndexLayer; use pd::testnet::{ - generate::testnet_generate, get_testnet_dir, join::testnet_join, parse_tm_address, - url_has_necessary_parts, + config::{get_testnet_dir, parse_tm_address, url_has_necessary_parts}, + generate::TestnetConfig, + join::testnet_join, }; use penumbra_proto::client::v1alpha1::{ oblivious_query_service_server::ObliviousQueryServiceServer, @@ -152,9 +150,27 @@ enum TestnetCommand { /// Testnet name [default: latest testnet]. #[clap(long)] chain_id: Option, - /// IP Address to start `tendermint` nodes on. Increments by three to make room for `pd` per node. - #[clap(long, default_value = "192.167.10.11")] - starting_ip: Ipv4Addr, + /// Base hostname for a validator's p2p service. If multiple validators + /// exist in the genesis, e.g. via `--validators-input-file`, then + /// numeric suffixes are automatically added, e.g. "-0", "-1", etc. + /// Helpful for when you know the validator DNS names ahead of time, + /// e.g. in Kubernetes service addresses. These option is most useful + /// to provide peering on a private network setup. If you plan to expose + /// the validator P2P services to the internet, see the `--external-addresses` option. + #[clap(long)] + peer_address_template: Option, + + /// Public addresses and ports for the Tendermint P2P services of the genesis + /// validator. Accepts comma-separated values, to support multiple validators. + /// If `--validators-input-file` is used to increase the number + /// of validators, and the `--external-addresses` flag is set, then the number of + /// external addresses must equal the number of validators. See the + /// `--peer-address-template` flag if you don't plan to expose the network + /// to public peers. + #[clap(long)] + // TODO we should support DNS names here. However, there are complications: + // https://github.com/tendermint/tendermint/issues/1521 + external_addresses: Option, }, /// Like `testnet generate`, but joins the testnet to which the specified node belongs @@ -456,11 +472,7 @@ async fn main() -> anyhow::Result<()> { RootCommand::Testnet { tn_cmd: TestnetCommand::Generate { - // TODO this config is gated on a "populate persistent peers" - // setting in the Go tendermint binary. Populating the persistent - // peers will be useful in local setups until peer discovery via a seed - // works. - starting_ip, + peer_address_template, timeout_commit, epoch_duration, unbonding_epochs, @@ -469,6 +481,7 @@ async fn main() -> anyhow::Result<()> { validators_input_file, chain_id, preserve_chain_id, + external_addresses, }, testnet_dir, } => { @@ -493,18 +506,44 @@ async fn main() -> anyhow::Result<()> { ); } + // Unpack external address information into a vec, since there could be multiple + // values. We don't yet know how many validators will be in the genesis, but the + // Testnet::generate constructor will assert that the number of external addresses, + // if Some, is equal to the number of validators. + let external_addresses: anyhow::Result> = + match external_addresses { + Some(a) => a + .split(',') + .map(|x| { + x.parse() + .context(format!("Failed to parse external address: {x}")) + }) + .collect(), + None => Ok(Vec::new()), + }; + + let external_addresses = external_addresses?; + // Build and write local configs based on input flags. - testnet_generate( - output_dir, + tracing::info!(?chain_id, "Generating network config"); + let t = TestnetConfig::generate( &chain_id, - active_validator_limit, + Some(output_dir), + peer_address_template, + Some(external_addresses), + allocations_input_file, + validators_input_file, timeout_commit, + active_validator_limit, epoch_duration, unbonding_epochs, - starting_ip, - validators_input_file, - allocations_input_file, )?; + tracing::info!( + n_validators = t.validators.len(), + chain_id = %t.genesis.chain_id, + "Writing config files for network" + ); + t.write_configs()?; } } Ok(()) diff --git a/crates/bin/pd/src/testnet.rs b/crates/bin/pd/src/testnet.rs index 542bfa23d7..bcc15a87c4 100644 --- a/crates/bin/pd/src/testnet.rs +++ b/crates/bin/pd/src/testnet.rs @@ -1,288 +1,6 @@ //! Methods and types used for generating testnet configurations. //! Mostly relevant until Penumbra reaches mainnet. -use anyhow::Context; -use decaf377_rdsa::{SigningKey, SpendAuth, VerificationKey}; -use directories::UserDirs; -use penumbra_chain::genesis::AppState; -use penumbra_keys::keys::{SpendKey, SpendKeyBytes}; -use penumbra_wallet::KeyStore; -use rand::Rng; -use rand_core::OsRng; -use regex::{Captures, Regex}; -use serde::Deserialize; -use std::{ - env::current_dir, - fs::{self, File}, - io::Write, - net::SocketAddr, - path::PathBuf, - str::FromStr, -}; -use tendermint::{node::Id, Genesis, Moniker, PrivateKey}; -use tendermint_config::{ - net::Address as TendermintAddress, NodeKey, PrivValidatorKey, TendermintConfig, -}; -use url::Url; +pub mod config; pub mod generate; pub mod join; - -/// Use a hard-coded Tendermint config as a base template, substitute -/// values via a typed interface, and rerender as TOML. -pub fn generate_tm_config( - node_name: &str, - peers: Vec, - external_address: Option, - tm_rpc_bind: Option, - tm_p2p_bind: Option, -) -> anyhow::Result { - tracing::debug!("List of TM peers: {:?}", peers); - let moniker: Moniker = Moniker::from_str(node_name)?; - let mut tm_config = - TendermintConfig::parse_toml(include_str!("../../../../testnets/tm_config_template.toml")) - .context("Failed to parse the TOML config template for Tendermint")?; - tm_config.moniker = moniker; - tm_config.p2p.seeds = peers; - tracing::debug!("External address looks like: {:?}", external_address); - tm_config.p2p.external_address = external_address; - // The Tendermint config wants URLs, not SocketAddrs, so we'll prepend protocol. - if let Some(rpc) = tm_rpc_bind { - tm_config.rpc.laddr = - parse_tm_address(None, &Url::parse(format!("tcp://{}", rpc).as_str())?)?; - } - if let Some(p2p) = tm_p2p_bind { - tm_config.p2p.laddr = - parse_tm_address(None, &Url::parse(format!("tcp://{}", p2p).as_str())?)?; - } - // We don't use pprof_laddr, and tendermint-rs incorrectly prepends "tcp://" to it, - // which emits an error on service start, so let's remove it entirely. - tm_config.rpc.pprof_laddr = None; - - Ok(tm_config) -} - -/// Construct a [`tendermint_config::net::Address`] from an optional node [`Id`] and `node_address`. -/// The `node_address` can be an IP address or a hostname. Supports custom ports, defaulting -/// to 26656 if not specified. -pub fn parse_tm_address( - node_id: Option<&Id>, - node_address: &Url, -) -> anyhow::Result { - let hostname = match node_address.host() { - Some(h) => h, - None => { - anyhow::bail!(format!("Could not find hostname in URL: {}", node_address)) - } - }; - // Default to 26656 for Tendermint port, if not specified. - let port = match node_address.port() { - Some(p) => p, - None => 26656, - }; - match node_id { - Some(id) => Ok(format!("{id}@{hostname}:{port}").parse()?), - None => Ok(format!("{hostname}:{port}").parse()?), - } -} - -pub struct ValidatorKeys { - // Penumbra spending key and viewing key for this node. - pub validator_id_sk: SigningKey, - pub validator_id_vk: VerificationKey, - // Consensus key for tendermint. - pub validator_cons_sk: tendermint::PrivateKey, - pub validator_cons_pk: tendermint::PublicKey, - // P2P auth key for tendermint. - pub node_key_sk: tendermint::PrivateKey, - #[allow(unused_variables, dead_code)] - pub node_key_pk: tendermint::PublicKey, - pub validator_spend_key: SpendKeyBytes, -} - -impl ValidatorKeys { - pub fn generate() -> Self { - // Create the spend key for this node. - // TODO: change to use seed phrase - let seed = SpendKeyBytes(OsRng.gen()); - let spend_key = SpendKey::from(seed.clone()); - - // Create signing key and verification key for this node. - let validator_id_sk = spend_key.spend_auth_key(); - let validator_id_vk = VerificationKey::from(validator_id_sk); - - let validator_cons_sk = ed25519_consensus::SigningKey::new(OsRng); - - // generate consensus key for tendermint. - let validator_cons_sk = tendermint::PrivateKey::Ed25519( - validator_cons_sk.as_bytes().as_slice().try_into().unwrap(), - ); - let validator_cons_pk = validator_cons_sk.public_key(); - - // generate P2P auth key for tendermint. - let node_key_sk = ed25519_consensus::SigningKey::new(OsRng); - let signing_key_bytes = node_key_sk.as_bytes().as_slice(); - - // generate consensus key for tendermint. - let node_key_sk = tendermint::PrivateKey::Ed25519(signing_key_bytes.try_into().unwrap()); - let node_key_pk = node_key_sk.public_key(); - - ValidatorKeys { - validator_id_sk: validator_id_sk.clone(), - validator_id_vk, - validator_cons_sk, - validator_cons_pk, - node_key_sk, - node_key_pk, - validator_spend_key: seed, - } - } -} - -#[derive(Deserialize)] -pub struct TendermintNodeKey { - pub id: String, - pub priv_key: TendermintPrivKey, -} - -#[derive(Deserialize)] -pub struct TendermintPrivKey { - #[serde(rename(serialize = "type"))] - pub key_type: String, - pub value: PrivateKey, -} - -/// Expand tildes in a path. -/// Modified from `` -pub fn canonicalize_path(input: &str) -> PathBuf { - let tilde = Regex::new(r"^~(/|$)").unwrap(); - if input.starts_with('/') { - // if the input starts with a `/`, we use it as is - input.into() - } else if tilde.is_match(input) { - // if the input starts with `~` as first token, we replace - // this `~` with the user home directory - PathBuf::from(&*tilde.replace(input, |c: &Captures| { - if let Some(user_dirs) = UserDirs::new() { - format!("{}{}", user_dirs.home_dir().to_string_lossy(), &c[1],) - } else { - c[0].to_string() - } - })) - } else { - PathBuf::from(format!("{}/{}", current_dir().unwrap().display(), input)) - } -} - -/// Create local config files for `pd` and `tendermint`. -pub fn write_configs( - node_dir: PathBuf, - vk: &ValidatorKeys, - genesis: &Genesis, - tm_config: TendermintConfig, -) -> anyhow::Result<()> { - let mut pd_dir = node_dir.clone(); - let mut tm_dir = node_dir; - - pd_dir.push("pd"); - tm_dir.push("tendermint"); - - let mut node_config_dir = tm_dir.clone(); - node_config_dir.push("config"); - - let mut node_data_dir = tm_dir.clone(); - node_data_dir.push("data"); - - fs::create_dir_all(&node_config_dir)?; - fs::create_dir_all(&node_data_dir)?; - fs::create_dir_all(&pd_dir)?; - - let mut genesis_file_path = node_config_dir.clone(); - genesis_file_path.push("genesis.json"); - tracing::info!(genesis_file_path = %genesis_file_path.display(), "writing genesis"); - let mut genesis_file = File::create(genesis_file_path)?; - genesis_file.write_all(serde_json::to_string_pretty(&genesis)?.as_bytes())?; - - let mut config_file_path = node_config_dir.clone(); - config_file_path.push("config.toml"); - tracing::info!(config_file_path = %config_file_path.display(), "writing tendermint config.toml"); - let mut config_file = File::create(config_file_path)?; - config_file.write_all(toml::to_string(&tm_config)?.as_bytes())?; - - // Write this node's node_key.json - // the underlying type doesn't implement Copy or Clone (for the best) - let priv_key = - tendermint::PrivateKey::Ed25519(vk.node_key_sk.ed25519_signing_key().unwrap().clone()); - - let node_key = NodeKey { priv_key }; - let mut node_key_file_path = node_config_dir.clone(); - node_key_file_path.push("node_key.json"); - tracing::info!(node_key_file_path = %node_key_file_path.display(), "writing node key file"); - let mut node_key_file = File::create(node_key_file_path)?; - node_key_file.write_all(serde_json::to_string_pretty(&node_key)?.as_bytes())?; - - // Write this node's priv_validator_key.json - let address: tendermint::account::Id = vk.validator_cons_pk.into(); - // the underlying type doesn't implement Copy or Clone (for the best) - - let priv_key = tendermint::PrivateKey::Ed25519( - vk.validator_cons_sk.ed25519_signing_key().unwrap().clone(), - ); - - let priv_validator_key = PrivValidatorKey { - address, - pub_key: vk.validator_cons_pk, - priv_key, - }; - let mut priv_validator_key_file_path = node_config_dir.clone(); - priv_validator_key_file_path.push("priv_validator_key.json"); - tracing::info!(priv_validator_key_file_path = %priv_validator_key_file_path.display(), "writing validator private key"); - let mut priv_validator_key_file = File::create(priv_validator_key_file_path)?; - priv_validator_key_file - .write_all(serde_json::to_string_pretty(&priv_validator_key)?.as_bytes())?; - - // Write the initial validator state: - let mut priv_validator_state_file_path = node_data_dir.clone(); - priv_validator_state_file_path.push("priv_validator_state.json"); - tracing::info!(priv_validator_state_file_path = %priv_validator_state_file_path.display(), "writing validator state"); - let mut priv_validator_state_file = File::create(priv_validator_state_file_path)?; - priv_validator_state_file.write_all(get_validator_state().as_bytes())?; - - // Write the validator's spend key: - let mut validator_spend_key_file_path = node_config_dir.clone(); - validator_spend_key_file_path.push("validator_custody.json"); - tracing::info!(validator_spend_key_file_path = %validator_spend_key_file_path.display(), "writing validator custody file"); - let mut validator_spend_key_file = File::create(validator_spend_key_file_path)?; - let validator_wallet = KeyStore { - spend_key: vk.validator_spend_key.clone().into(), - }; - validator_spend_key_file - .write_all(serde_json::to_string_pretty(&validator_wallet)?.as_bytes())?; - - Ok(()) -} - -// Easiest to hardcode since we never change these. -pub fn get_validator_state() -> String { - r#"{ - "height": "0", - "round": 0, - "step": 0 -} -"# - .to_string() -} - -/// Convert an optional CLI arg into a [`PathBuf`], defaulting to -/// `~/.penumbra/testnet_data`. -pub fn get_testnet_dir(testnet_dir: Option) -> PathBuf { - // By default output directory will be in `~/.penumbra/testnet_data/` - match testnet_dir { - Some(o) => o, - None => canonicalize_path("~/.penumbra/testnet_data"), - } -} - -/// Check that a [Url] has all the necessary parts defined for use as a CLI arg. -pub fn url_has_necessary_parts(url: &Url) -> bool { - url.scheme() != "" && url.has_host() && url.port().is_some() -} diff --git a/crates/bin/pd/src/testnet/config.rs b/crates/bin/pd/src/testnet/config.rs new file mode 100644 index 0000000000..edebfbac3a --- /dev/null +++ b/crates/bin/pd/src/testnet/config.rs @@ -0,0 +1,287 @@ +use anyhow::Context; +use decaf377_rdsa::{SigningKey, SpendAuth, VerificationKey}; +use directories::UserDirs; +use penumbra_chain::genesis::AppState; +use penumbra_keys::keys::{SpendKey, SpendKeyBytes}; +use penumbra_wallet::KeyStore; +use rand::Rng; +use rand_core::OsRng; +use regex::{Captures, Regex}; +use serde::Deserialize; +use std::{ + env::current_dir, + fs::{self, File}, + io::Write, + net::SocketAddr, + path::PathBuf, + str::FromStr, +}; +use tendermint::{node::Id, Genesis, Moniker, PrivateKey}; +use tendermint_config::{ + net::Address as TendermintAddress, NodeKey, PrivValidatorKey, TendermintConfig, +}; +use url::Url; + +use crate::testnet::generate::TestnetValidator; + +/// Wrapper for a [TendermintConfig], with a constructor for convenient defaults. +pub struct TestnetTendermintConfig(pub TendermintConfig); + +impl TestnetTendermintConfig { + /// Use a hard-coded Tendermint config as a base template, substitute + /// values via a typed interface, and rerender as TOML. + pub fn new( + node_name: &str, + peers: Vec, + external_address: Option, + tm_rpc_bind: Option, + tm_p2p_bind: Option, + ) -> anyhow::Result { + tracing::debug!("List of TM peers: {:?}", peers); + let moniker: Moniker = Moniker::from_str(node_name)?; + let mut tm_config = TendermintConfig::parse_toml(include_str!( + "../../../../../testnets/tm_config_template.toml" + )) + .context("Failed to parse the TOML config template for Tendermint")?; + tm_config.moniker = moniker; + tm_config.p2p.seeds = peers; + tracing::debug!("External address looks like: {:?}", external_address); + tm_config.p2p.external_address = external_address; + // The Tendermint config wants URLs, not SocketAddrs, so we'll prepend protocol. + if let Some(rpc) = tm_rpc_bind { + tm_config.rpc.laddr = + parse_tm_address(None, &Url::parse(format!("tcp://{}", rpc).as_str())?)?; + } + if let Some(p2p) = tm_p2p_bind { + tm_config.p2p.laddr = + parse_tm_address(None, &Url::parse(format!("tcp://{}", p2p).as_str())?)?; + } + // We don't use pprof_laddr, and tendermint-rs incorrectly prepends "tcp://" to it, + // which emits an error on service start, so let's remove it entirely. + tm_config.rpc.pprof_laddr = None; + + Ok(Self(tm_config)) + } +} + +impl TestnetTendermintConfig { + /// Write Tendermint config files to disk. This includes not only the `config.toml` file, + /// but also all keypairs required for node and/or validator identity. + pub fn write_config( + &self, + node_dir: PathBuf, + v: &TestnetValidator, + genesis: &Genesis, + ) -> anyhow::Result<()> { + // We'll also create the pd state directory here, since it's convenient. + let pd_dir = node_dir.clone().join("pd"); + let tm_data_dir = node_dir.clone().join("tendermint").join("data"); + let tm_config_dir = node_dir.clone().join("tendermint").join("config"); + + tracing::info!(config_dir = %node_dir.display(), "Writing validator configs to"); + + fs::create_dir_all(pd_dir)?; + fs::create_dir_all(&tm_data_dir)?; + fs::create_dir_all(&tm_config_dir)?; + + let genesis_file_path = tm_config_dir.clone().join("genesis.json"); + tracing::debug!(genesis_file_path = %genesis_file_path.display(), "writing genesis"); + let mut genesis_file = File::create(genesis_file_path)?; + genesis_file.write_all(serde_json::to_string_pretty(&genesis)?.as_bytes())?; + + let tm_config_filepath = tm_config_dir.clone().join("config.toml"); + tracing::debug!(tendermint_config = %tm_config_filepath.display(), "writing tendermint config.toml"); + let mut tm_config_file = File::create(tm_config_filepath)?; + tm_config_file.write_all(toml::to_string(&self.0)?.as_bytes())?; + + // Write this node's node_key.json + // the underlying type doesn't implement Copy or Clone (for the best) + let priv_key = tendermint::PrivateKey::Ed25519( + v.keys.node_key_sk.ed25519_signing_key().unwrap().clone(), + ); + + let node_key = NodeKey { priv_key }; + let tm_node_key_filepath = tm_config_dir.clone().join("node_key.json"); + tracing::debug!(tm_node_key_filepath = %tm_node_key_filepath.display(), "writing node key file"); + let mut tm_node_key_file = File::create(tm_node_key_filepath)?; + tm_node_key_file.write_all(serde_json::to_string_pretty(&node_key)?.as_bytes())?; + + // Write this node's priv_validator_key.json + let priv_validator_key_filepath = tm_config_dir.clone().join("priv_validator_key.json"); + tracing::debug!(priv_validator_key_filepath = %priv_validator_key_filepath.display(), "writing validator private key"); + let mut priv_validator_key_file = File::create(priv_validator_key_filepath)?; + let priv_validator_key: PrivValidatorKey = v.keys.priv_validator_key()?; + priv_validator_key_file + .write_all(serde_json::to_string_pretty(&priv_validator_key)?.as_bytes())?; + + // Write the initial validator state: + let priv_validator_state_filepath = tm_data_dir.clone().join("priv_validator_state.json"); + tracing::debug!(priv_validator_state_filepath = %priv_validator_state_filepath.display(), "writing validator state"); + let mut priv_validator_state_file = File::create(priv_validator_state_filepath)?; + priv_validator_state_file.write_all(TestnetValidator::initial_state().as_bytes())?; + + // Write the validator's spend key: + let validator_spend_key_filepath = tm_config_dir.clone().join("validator_custody.json"); + tracing::debug!(validator_spend_key_filepath = %validator_spend_key_filepath.display(), "writing validator custody file"); + let mut validator_spend_key_file = File::create(validator_spend_key_filepath)?; + let validator_wallet = KeyStore { + spend_key: v.keys.validator_spend_key.clone().into(), + }; + validator_spend_key_file + .write_all(serde_json::to_string_pretty(&validator_wallet)?.as_bytes())?; + + Ok(()) + } +} + +/// Construct a [`tendermint_config::net::Address`] from an optional node [`Id`] and `node_address`. +/// The `node_address` can be an IP address or a hostname. Supports custom ports, defaulting +/// to 26656 if not specified. +pub fn parse_tm_address( + node_id: Option<&Id>, + node_address: &Url, +) -> anyhow::Result { + let hostname = match node_address.host() { + Some(h) => h, + None => { + anyhow::bail!(format!("Could not find hostname in URL: {}", node_address)) + } + }; + // Default to 26656 for Tendermint port, if not specified. + let port = node_address.port().unwrap_or(26656); + match node_id { + Some(id) => Ok(format!("{id}@{hostname}:{port}").parse()?), + None => Ok(format!("{hostname}:{port}").parse()?), + } +} + +/// Collection of all keypairs required for a Penumbra validator. +/// Used to generate a stable identity for a [`TestnetValidator`]. +#[derive(Deserialize)] +pub struct ValidatorKeys { + /// Penumbra spending key and viewing key for this node. + pub validator_id_sk: SigningKey, + pub validator_id_vk: VerificationKey, + pub validator_spend_key: SpendKeyBytes, + /// Consensus key for tendermint. + pub validator_cons_sk: tendermint::PrivateKey, + pub validator_cons_pk: tendermint::PublicKey, + /// P2P auth key for tendermint. + pub node_key_sk: tendermint::PrivateKey, + #[allow(unused_variables, dead_code)] + pub node_key_pk: tendermint::PublicKey, +} + +impl ValidatorKeys { + pub fn generate() -> Self { + // Create the spend key for this node. + // TODO: change to use seed phrase + let seed = SpendKeyBytes(OsRng.gen()); + let spend_key = SpendKey::from(seed.clone()); + + // Create signing key and verification key for this node. + let validator_id_sk = spend_key.spend_auth_key(); + let validator_id_vk = VerificationKey::from(validator_id_sk); + + let validator_cons_sk = ed25519_consensus::SigningKey::new(OsRng); + + // generate consensus key for tendermint. + let validator_cons_sk = tendermint::PrivateKey::Ed25519( + validator_cons_sk.as_bytes().as_slice().try_into().unwrap(), + ); + let validator_cons_pk = validator_cons_sk.public_key(); + + // generate P2P auth key for tendermint. + let node_key_sk = ed25519_consensus::SigningKey::new(OsRng); + let signing_key_bytes = node_key_sk.as_bytes().as_slice(); + + // generate consensus key for tendermint. + let node_key_sk = tendermint::PrivateKey::Ed25519(signing_key_bytes.try_into().unwrap()); + let node_key_pk = node_key_sk.public_key(); + + ValidatorKeys { + validator_id_sk: validator_id_sk.clone(), + validator_id_vk, + validator_cons_sk, + validator_cons_pk, + node_key_sk, + node_key_pk, + validator_spend_key: seed, + } + } + /// Format the p2p consensus keypair into a struct suitable for serialization + /// directly as `priv_validator_key.json` for Tendermint config. + pub fn priv_validator_key(&self) -> anyhow::Result { + let address: tendermint::account::Id = self.validator_cons_pk.into(); + let priv_key = tendermint::PrivateKey::Ed25519( + self.validator_cons_sk + .ed25519_signing_key() + .ok_or(anyhow::anyhow!( + "Failed during loop of signing key for TestnetValidator" + ))? + .clone(), + ); + let priv_validator_key = PrivValidatorKey { + address, + pub_key: self.validator_cons_pk, + priv_key, + }; + Ok(priv_validator_key) + } +} + +impl Default for ValidatorKeys { + fn default() -> Self { + Self::generate() + } +} + +#[derive(Deserialize)] +pub struct TendermintNodeKey { + pub id: String, + pub priv_key: TendermintPrivKey, +} + +#[derive(Deserialize)] +pub struct TendermintPrivKey { + #[serde(rename(serialize = "type"))] + pub key_type: String, + pub value: PrivateKey, +} + +/// Expand tildes in a path. +/// Modified from `` +pub fn canonicalize_path(input: &str) -> PathBuf { + let tilde = Regex::new(r"^~(/|$)").unwrap(); + if input.starts_with('/') { + // if the input starts with a `/`, we use it as is + input.into() + } else if tilde.is_match(input) { + // if the input starts with `~` as first token, we replace + // this `~` with the user home directory + PathBuf::from(&*tilde.replace(input, |c: &Captures| { + if let Some(user_dirs) = UserDirs::new() { + format!("{}{}", user_dirs.home_dir().to_string_lossy(), &c[1],) + } else { + c[0].to_string() + } + })) + } else { + PathBuf::from(format!("{}/{}", current_dir().unwrap().display(), input)) + } +} + +/// Convert an optional CLI arg into a [`PathBuf`], defaulting to +/// `~/.penumbra/testnet_data`. +pub fn get_testnet_dir(testnet_dir: Option) -> PathBuf { + // By default output directory will be in `~/.penumbra/testnet_data/` + match testnet_dir { + Some(o) => o, + None => canonicalize_path("~/.penumbra/testnet_data"), + } +} + +/// Check that a [Url] has all the necessary parts defined for use as a CLI arg. +pub fn url_has_necessary_parts(url: &Url) -> bool { + url.scheme() != "" && url.has_host() && url.port().is_some() +} diff --git a/crates/bin/pd/src/testnet/generate.rs b/crates/bin/pd/src/testnet/generate.rs index 211dc5574e..789e76f483 100644 --- a/crates/bin/pd/src/testnet/generate.rs +++ b/crates/bin/pd/src/testnet/generate.rs @@ -1,7 +1,7 @@ //! Logic for creating a new testnet configuration. //! Used for deploying (approximately weekly) testnets //! for Penumbra. -use crate::testnet::{generate_tm_config, parse_tm_address, write_configs, ValidatorKeys}; +use crate::testnet::config::{get_testnet_dir, TestnetTendermintConfig, ValidatorKeys}; use anyhow::{Context, Result}; use penumbra_chain::genesis; use penumbra_chain::{genesis::Allocation, params::ChainParameters}; @@ -15,258 +15,313 @@ use std::{ fmt, fs::File, io::Read, - net::Ipv4Addr, path::PathBuf, str::FromStr, time::{Duration, SystemTime, UNIX_EPOCH}, }; use tendermint::{node, public_key::Algorithm, Genesis, Time}; -use url::Url; +use tendermint_config::net::Address as TendermintAddress; -/// Create a new testnet definition, including genesis and at least one -/// validator config. Write all configs to the target testnet dir, -/// defaulting to `~/.penumbra/testnet_data`, as usual. -#[allow(clippy::too_many_arguments)] -pub fn testnet_generate( - testnet_dir: PathBuf, - chain_id: &str, - active_validator_limit: Option, - timeout_commit: Option, - epoch_duration: Option, - unbonding_epochs: Option, - starting_ip: Ipv4Addr, - validators_input_file: Option, - allocations_input_file: Option, -) -> anyhow::Result<()> { - let genesis_time = Time::from_unix_timestamp( - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("time travels linearly in a forward direction") - .as_secs() as i64, - 0, - ) - .expect("able to convert current time into Time"); - - // Parse allocations from input file or default to latest testnet allocations computed - // in the build script - let mut allocations = if let Some(allocations_input_file) = allocations_input_file { - let allocations_file = File::open(&allocations_input_file) - .with_context(|| format!("cannot open file {allocations_input_file:?}"))?; - parse_allocations(allocations_file).with_context(|| { - format!("could not parse allocations file {allocations_input_file:?}") - })? - } else { - static LATEST_ALLOCATIONS: &str = include_str!(env!("PD_LATEST_TESTNET_ALLOCATIONS")); - parse_allocations(std::io::Cursor::new(LATEST_ALLOCATIONS)).with_context(|| { - format!( - "could not parse default latest testnet allocations file {:?}", - env!("PD_LATEST_TESTNET_ALLOCATIONS") - ) - })? - }; - - // Parse validators from input file or default to latest testnet validators computed in - // the build script - let testnet_validators = if let Some(validators_input_file) = validators_input_file { - let validators_file = File::open(&validators_input_file) - .with_context(|| format!("cannot open file {validators_input_file:?}"))?; - parse_validators(validators_file) - .with_context(|| format!("could not parse validators file {validators_input_file:?}"))? - } else { - static LATEST_VALIDATORS: &str = include_str!(env!("PD_LATEST_TESTNET_VALIDATORS")); - parse_validators(std::io::Cursor::new(LATEST_VALIDATORS)).with_context(|| { - format!( - "could not parse default latest testnet validators file {:?}", - env!("PD_LATEST_TESTNET_VALIDATORS") - ) - })? - }; - - let mut validator_keys = Vec::::new(); - // Generate a keypair for each validator - let num_validator_nodes = testnet_validators.len(); - assert!( - num_validator_nodes > 0, - "must have at least one validator node" - ); - for _ in 0..num_validator_nodes { - let vk = ValidatorKeys::generate(); - - let spend_key = SpendKey::from(vk.validator_spend_key.clone()); - let fvk = spend_key.full_viewing_key(); - let ivk = fvk.incoming(); - let (dest, _dtk_d) = ivk.payment_address(0u32.into()); +/// Represents a Penumbra network config, including initial validators +/// and allocations at genesis time. +pub struct TestnetConfig { + /// The name of the network + pub name: String, + /// The Tendermint genesis for initial chain state. + pub genesis: Genesis, + /// Path to local directory where config files will be written to + pub testnet_dir: PathBuf, + /// Set of validators at genesis. Uses the convenient wrapper type + /// to generate config files. + pub testnet_validators: Vec, + /// Set of validators at genesis. This is the literal type embedded + /// inside configs, including the keys + pub validators: Vec, + /// Hostname as string for a validator's p2p service. Will have + /// numbers affixed to it for each validator, e.g. "-0", "-1", etc. + pub peer_address_template: Option, + /// The Tendermint `consensus.timeout_commit` value, controlling how long Tendermint should + /// wait after committing a block, before starting on the new height. If unspecified, `5s`. + pub tendermint_timeout_commit: Option, +} - // Add a default 1 upenumbra allocation to the validator. - let identity_key: IdentityKey = IdentityKey(fvk.spend_verification_key().clone()); - let delegation_denom = DelegationToken::from(&identity_key).denom(); - allocations.push(Allocation { - address: dest, - // Add an initial allocation of 25,000 delegation tokens, - // starting them with 2.5x the individual allocations to discord users. - // 25,000 delegation tokens * 1e6 udelegation factor - amount: (25_000 * 10u128.pow(6)).into(), - denom: delegation_denom.to_string(), - }); +impl TestnetConfig { + /// Create a new testnet configuration, optionally customizing the allocations and validator + /// set. By default, will use the prepared Discord allocations and Penumbra Labs CI validator + /// configs. + pub fn generate( + chain_id: &str, + testnet_dir: Option, + peer_address_template: Option, + external_addresses: Option>, + allocations_input_file: Option, + validators_input_file: Option, + tendermint_timeout_commit: Option, + active_validator_limit: Option, + epoch_duration: Option, + unbonding_epochs: Option, + ) -> anyhow::Result { + let peer_address = peer_address_template.unwrap_or("127.0.0.1".to_string()); + let external_addresses = external_addresses.unwrap_or(Vec::new()); + + let testnet_validators = Self::collect_validators( + validators_input_file, + peer_address.clone(), + external_addresses, + )?; + + let mut allocations = Self::collect_allocations(allocations_input_file)?; + + for v in testnet_validators.iter() { + allocations.push(v.delegation_allocation()?); + } - validator_keys.push(vk); - } + // Convert to domain type, for use with other Penumbra interfaces. + // We do this conversion once and store it in the struct for convenience. + let validators = &testnet_validators + .iter() + .map(|v| v.try_into().unwrap()) + .collect::>(); - let ip_addrs = validator_keys - .iter() - .enumerate() - .map(|(i, _vk)| { - let a = starting_ip.octets(); - Ipv4Addr::new(a[0], a[1], a[2], a[3] + (10 * i as u8)) - }) - .collect::>(); - - let validators = testnet_validators - .iter() - .enumerate() - .map(|(i, v)| { - let vk = &validator_keys[i]; - Ok(Validator { - // Currently there's no way to set validator keys beyond - // manually editing the genesis.json. Otherwise they - // will be randomly generated keys. - identity_key: IdentityKey(vk.validator_id_vk), - governance_key: GovernanceKey(vk.validator_id_vk), - consensus_key: vk.validator_cons_pk, - name: v.name.clone(), - website: v.website.clone(), - description: v.description.clone(), - enabled: true, - funding_streams: FundingStreams::try_from( - v.funding_streams - .iter() - .map(|fs| { - Ok(FundingStream::ToAddress { - address: Address::from_str(&fs.address) - .context("invalid funding stream address in validators.json")?, - rate_bps: fs.rate_bps, - }) - }) - .collect::>>()?, - ) - .context("unable to construct funding streams from validators.json")?, - sequence_number: v.sequence_number, - }) - }) - .collect::>>()?; - - let default_params = ChainParameters::default(); - let active_validator_limit = - active_validator_limit.unwrap_or(default_params.active_validator_limit); - let epoch_duration = epoch_duration.unwrap_or(default_params.epoch_duration); - let unbonding_epochs = unbonding_epochs.unwrap_or(default_params.unbonding_epochs); - - let app_state = genesis::AppState { - allocations: allocations.clone(), - chain_params: ChainParameters { - chain_id: chain_id.to_string(), + let app_state = Self::make_appstate( + chain_id, + allocations, + validators.to_vec(), + active_validator_limit, epoch_duration, unbonding_epochs, - active_validator_limit, - ..Default::default() - }, - validators: validators.into_iter().map(Into::into).collect(), - }; - - // Create the genesis data shared by all nodes - let validator_genesis = Genesis { - genesis_time, - chain_id: chain_id - .parse::() - .expect("able to create chain ID"), - initial_height: 0, - consensus_params: tendermint::consensus::Params { - block: tendermint::block::Size { - max_bytes: 22020096, - max_gas: -1, - // minimum time increment between consecutive blocks - time_iota_ms: 500, - }, - // TODO Should these correspond with values used within `pd` for penumbra epochs? - evidence: tendermint::evidence::Params { - max_age_num_blocks: 100000, - // 1 day - max_age_duration: tendermint::evidence::Duration(Duration::new(86400, 0)), - max_bytes: 1048576, - }, - validator: tendermint::consensus::params::ValidatorParams { - pub_key_types: vec![Algorithm::Ed25519], - }, - version: Some(tendermint::consensus::params::VersionParams { app: 0 }), - }, - // always empty in genesis json - app_hash: tendermint::AppHash::default(), - app_state, - // List of initial validators. Note this may be overridden entirely by - // the application, and may be left empty to make explicit that the - // application will initialize the validator set with ResponseInitChain. - // - https://docs.tendermint.com/v0.32/tendermint-core/using-tendermint.html - // For penumbra, we can leave this empty since the app_state also contains Validator - // configs. - validators: vec![], - }; - - for (n, vk) in validator_keys.iter().enumerate() { - let node_name = format!("node{n}"); - - // Create the directory for this node - let mut node_dir = testnet_dir.clone(); - node_dir.push(node_name.clone()); - - // Write this node's config.toml - // Note that this isn't a re-implementation of the `Config` type from - // Tendermint (https://github.com/tendermint/tendermint/blob/6291d22f46f4c4f9121375af700dbdafa51577e7/config/config.go#L92) - // so if they change their defaults or the available fields, that won't be reflected in our template. - // TODO: grab all peer pubkeys instead of self pubkey - let my_ip = &ip_addrs[n]; - // Each node should include only the IPs for *other* nodes in their peers list. - let ips_minus_mine = ip_addrs - .iter() + )?; + let genesis = Self::make_genesis(app_state)?; + + Ok(TestnetConfig { + name: chain_id.to_owned(), + genesis, + testnet_dir: get_testnet_dir(testnet_dir), + testnet_validators, + validators: validators.to_vec(), + peer_address_template: Some(peer_address), + tendermint_timeout_commit, + }) + } + + /// Prepare set of initial validators present at genesis. Optionally reads config values from a + /// JSON file, otherwise falls back to the Penumbra Labs CI validator configs used for + /// testnets. + fn collect_validators( + validators_input_file: Option, + peer_address_template: String, + external_addresses: Vec, + ) -> anyhow::Result> { + let testnet_validators = if let Some(validators_input_file) = validators_input_file { + TestnetValidator::from_json(validators_input_file)? + } else { + static LATEST_VALIDATORS: &str = include_str!(env!("PD_LATEST_TESTNET_VALIDATORS")); + TestnetValidator::from_reader(std::io::Cursor::new(LATEST_VALIDATORS)).with_context( + || { + format!( + "could not parse default latest testnet validators file {:?}", + env!("PD_LATEST_TESTNET_VALIDATORS") + ) + }, + )? + }; + + if external_addresses.len() > 0 { + if external_addresses.len() != testnet_validators.len() { + anyhow::bail!("Number of validators did not equal number of external addresses"); + } + } + + Ok(testnet_validators + .into_iter() .enumerate() - .filter(|(_, p)| *p != my_ip) - .map(|(n, ip)| { - ( - node::Id::from(validator_keys[n].node_key_pk.ed25519().unwrap()), - Url::parse(format!("tcp://{ip}:26656").as_str()).unwrap(), - ) + .map(|(i, v)| TestnetValidator { + peer_address_template: Some(format!("{peer_address_template}-{i}")), + external_address: external_addresses.get(i).cloned(), + ..v }) - .filter_map(|(id, ip)| parse_tm_address(Some(&id), &ip).ok()) - .collect::>(); - let mut tm_config = generate_tm_config(&node_name, ips_minus_mine, None, None, None)?; - if let Some(timeout_commit) = timeout_commit { - tm_config.consensus.timeout_commit = timeout_commit; - } + .collect()) + } - write_configs(node_dir, vk, &validator_genesis, tm_config)?; + /// Prepare a set of initial [Allocation]s present at genesis. Optionally reads allocation + /// files a CSV file, otherwise falls back to the historical requests of the testnet faucet + /// in the Penumbra Discord channel. + fn collect_allocations( + allocations_input_file: Option, + ) -> anyhow::Result> { + if let Some(ref allocations_input_file) = allocations_input_file { + Ok( + TestnetAllocation::from_csv(allocations_input_file.to_path_buf()).with_context( + || format!("could not parse allocations file {allocations_input_file:?}"), + )?, + ) + } else { + // Default to latest testnet allocations computed in the build script. + static LATEST_ALLOCATIONS: &str = include_str!(env!("PD_LATEST_TESTNET_ALLOCATIONS")); + Ok( + TestnetAllocation::from_reader(std::io::Cursor::new(LATEST_ALLOCATIONS)) + .with_context(|| { + format!( + "could not parse default latest testnet allocations file {:?}", + env!("PD_LATEST_TESTNET_ALLOCATIONS") + ) + })?, + ) + } } - Ok(()) -} -fn parse_allocations(input: impl Read) -> Result> { - let mut rdr = csv::Reader::from_reader(input); - let mut res = vec![]; - for (line, result) in rdr.deserialize().enumerate() { - let record: TestnetAllocation = result?; - let record: genesis::Allocation = record - .try_into() - .with_context(|| format!("invalid address in entry {line} of allocations file"))?; - res.push(record); + /// Build initial state for Penumbra application, for inclusion in Tendermint genesis. + fn make_appstate( + chain_id: &str, + allocations: Vec, + validators: Vec, + active_validator_limit: Option, + epoch_duration: Option, + unbonding_epochs: Option, + ) -> anyhow::Result { + // Look up default chain params, so we can fill in defaults. + let default_params = ChainParameters::default(); + let app_state = genesis::AppState { + allocations: allocations.clone(), + chain_params: ChainParameters { + chain_id: chain_id.to_string(), + // Fall back to chain param defaults + active_validator_limit: active_validator_limit + .unwrap_or(default_params.active_validator_limit), + epoch_duration: epoch_duration.unwrap_or(default_params.epoch_duration), + unbonding_epochs: unbonding_epochs.unwrap_or(default_params.unbonding_epochs), + ..Default::default() + }, + // Convert to protobuf types + validators: validators.into_iter().map(Into::into).collect(), + }; + Ok(app_state) } - if res.len() < 1 { - anyhow::bail!("parsed no entries from allocations input file; is the file valid CSV?"); + /// Build Tendermint genesis data, based on Penumbra initial application state. + fn make_genesis(app_state: genesis::AppState) -> anyhow::Result> { + // Use now as genesis time + let genesis_time = Time::from_unix_timestamp( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("expected that time travels linearly in a forward direction")? + .as_secs() as i64, + 0, + ) + .context("failed to convert current time into Time")?; + + // Create Tendermint genesis data shared by all nodes + let genesis = Genesis { + genesis_time, + chain_id: app_state + .chain_params + .chain_id + .parse::() + .context("failed to parseto create chain ID")?, + initial_height: 0, + consensus_params: tendermint::consensus::Params { + block: tendermint::block::Size { + max_bytes: 22020096, + max_gas: -1, + // minimum time increment between consecutive blocks + time_iota_ms: 500, + }, + // TODO Should these correspond with values used within `pd` for penumbra epochs? + evidence: tendermint::evidence::Params { + max_age_num_blocks: 100000, + // 1 day + max_age_duration: tendermint::evidence::Duration(Duration::new(86400, 0)), + max_bytes: 1048576, + }, + validator: tendermint::consensus::params::ValidatorParams { + pub_key_types: vec![Algorithm::Ed25519], + }, + version: Some(tendermint::consensus::params::VersionParams { app: 0 }), + }, + // always empty in genesis json + app_hash: tendermint::AppHash::default(), + app_state, + // Set empty validator set for Tendermint config, which falls back to reading + // validators from the AppState, via ResponseInitChain: + // https://docs.tendermint.com/v0.32/tendermint-core/using-tendermint.html + validators: vec![], + }; + Ok(genesis) } - Ok(res) + /// Generate and write to disk the Tendermint configs for each validator at genesis. + pub fn write_configs(&self) -> anyhow::Result<()> { + // Loop over each validator and write its config separately. + for (n, v) in self.testnet_validators.iter().enumerate() { + // Create the directory for this node + let node_name = format!("node{n}"); + let node_dir = self.testnet_dir.clone().join(node_name.clone()); + + // Each node should include only the IPs for *other* nodes in their peers list. + let ips_minus_mine = self + .testnet_validators + .iter() + .map(|v| v.peering_address().unwrap()) + .filter(|a| *a != v.peering_address().unwrap()) + .collect::>(); + tracing::debug!(?ips_minus_mine, "Found these peer ips"); + + let external_address: Option = match &v.external_address { + Some(e) => Some(e.clone()), + None => None, + }; + let mut tm_config = TestnetTendermintConfig::new( + &node_name, + ips_minus_mine, + external_address, + None, + None, + )?; + if let Some(timeout_commit) = self.tendermint_timeout_commit { + tm_config.0.consensus.timeout_commit = timeout_commit; + } + tm_config.write_config(node_dir, v, &self.genesis)?; + } + Ok(()) + } } -fn parse_validators(input: impl Read) -> Result> { - Ok(serde_json::from_reader(input)?) +/// Create a new testnet definition, including genesis and at least one +/// validator config. Write all configs to the target testnet dir, +/// defaulting to `~/.penumbra/testnet_data`, as usual. +#[allow(clippy::too_many_arguments)] +pub fn testnet_generate( + testnet_dir: PathBuf, + chain_id: &str, + active_validator_limit: Option, + tendermint_timeout_commit: Option, + epoch_duration: Option, + unbonding_epochs: Option, + peer_address_template: Option, + external_addresses: Vec, + validators_input_file: Option, + allocations_input_file: Option, +) -> anyhow::Result<()> { + tracing::info!(?chain_id, "Generating network config"); + let t = TestnetConfig::generate( + chain_id, + Some(testnet_dir), + peer_address_template, + Some(external_addresses), + allocations_input_file, + validators_input_file, + tendermint_timeout_commit, + active_validator_limit, + epoch_duration, + unbonding_epochs, + )?; + tracing::info!( + n_validators = t.validators.len(), + chain_id = %t.genesis.chain_id, + "Writing config files for network" + ); + t.write_configs()?; + Ok(()) } /// Represents initial allocations to the testnet. @@ -278,21 +333,165 @@ pub struct TestnetAllocation { pub address: String, } +impl TestnetAllocation { + /// Import allocations from a CSV file. The format is simple: + /// + /// amount,denom,address + /// + /// Typically these CSV files are generated by Galileo. + pub fn from_csv(csv_filepath: PathBuf) -> Result> { + let allocations_file = File::open(&csv_filepath) + .with_context(|| format!("cannot open file {csv_filepath:?}"))?; + Self::from_reader(allocations_file) + } + /// Import allocations from a reader object that emits CSV. + pub fn from_reader(csv_input: impl Read) -> Result> { + let mut rdr = csv::Reader::from_reader(csv_input); + let mut res = vec![]; + for (line, result) in rdr.deserialize().enumerate() { + let record: TestnetAllocation = result?; + let record: genesis::Allocation = record.try_into().with_context(|| { + format!("invalid allocation in entry {line} of allocations file") + })?; + res.push(record); + } + + if res.is_empty() { + anyhow::bail!("parsed no entries from allocations input file; is the file valid CSV?"); + } + + Ok(res) + } +} + /// Represents a funding stream within a testnet configuration file. -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Clone)] pub struct TestnetFundingStream { pub rate_bps: u16, pub address: String, } /// Represents testnet validators in configuration files. -#[derive(Debug, Deserialize)] +#[derive(Deserialize)] pub struct TestnetValidator { pub name: String, pub website: String, pub description: String, pub funding_streams: Vec, + /// All validator identities pub sequence_number: u32, + /// Optional `external_address` field for Tendermint config. + /// Instructs peers to connect to this node's P2P service + /// on this address. + pub external_address: Option, + pub peer_address_template: Option, + #[serde(default)] + pub keys: ValidatorKeys, +} + +impl TestnetValidator { + /// Import validator configs from a JSON file. + pub fn from_json(json_filepath: PathBuf) -> Result> { + let validators_file = File::open(&json_filepath) + .with_context(|| format!("cannot open file {json_filepath:?}"))?; + Self::from_reader(validators_file) + } + /// Import validator configs from a reader object that emits JSON. + pub fn from_reader(input: impl Read) -> Result> { + Ok(serde_json::from_reader(input)?) + } + /// Generate iniital delegation allocation for inclusion in genesis. + pub fn delegation_allocation(&self) -> Result { + let spend_key = SpendKey::from(self.keys.validator_spend_key.clone()); + let fvk = spend_key.full_viewing_key(); + let ivk = fvk.incoming(); + let (dest, _dtk_d) = ivk.payment_address(0u32.into()); + + let identity_key: IdentityKey = IdentityKey(fvk.spend_verification_key().clone()); + let delegation_denom = DelegationToken::from(&identity_key).denom(); + Ok(Allocation { + address: dest, + // Add an initial allocation of 25,000 delegation tokens, + // starting them with 2.5x the individual allocations to discord users. + // 25,000 delegation tokens * 1e6 udelegation factor + amount: (25_000 * 10u128.pow(6)).into(), + denom: delegation_denom.to_string(), + }) + } + /// Return a URL for Tendermint P2P service for this node. + /// + /// In order for the set of genesis validators to communicate with each other, + /// they must have initial peer information seeded into their Tendermint config files. + pub fn peering_address(&self) -> anyhow::Result { + let tm_host = match &self.peer_address_template { + Some(h) => h, + None => "127.0.0.1", + }; + let tm_node_id = node::Id::from(self.keys.node_key_pk.ed25519().unwrap()); + let tm_url: TendermintAddress = format!("{tm_node_id}@{tm_host}:26656").parse()?; + Ok(tm_url) + } + + /// Hardcoded initial state for Tendermint, used for writing configs. + // Easiest to hardcode since we never change these. + pub fn initial_state() -> String { + r#"{ + "height": "0", + "round": 0, + "step": 0 + } + "# + .to_string() + } +} + +impl Default for TestnetValidator { + fn default() -> Self { + Self { + name: "".to_string(), + website: "".to_string(), + description: "".to_string(), + funding_streams: Vec::::new(), + sequence_number: 0, + external_address: None, + peer_address_template: None, + keys: ValidatorKeys::generate(), + } + } +} + +// The core Penumbra logic deals with `Validator`s, to make sure our convenient +// wrapper type can resolve as a `Validator` when needed. +impl TryFrom<&TestnetValidator> for Validator { + type Error = anyhow::Error; + fn try_from(tv: &TestnetValidator) -> anyhow::Result { + Ok(Validator { + // Currently there's no way to set validator keys beyond + // manually editing the genesis.json. Otherwise they + // will be randomly generated keys. + identity_key: IdentityKey(tv.keys.validator_id_vk), + governance_key: GovernanceKey(tv.keys.validator_id_vk), + consensus_key: tv.keys.validator_cons_pk, + name: tv.name.clone(), + website: tv.website.clone(), + description: tv.description.clone(), + enabled: true, + funding_streams: FundingStreams::try_from( + tv.funding_streams + .iter() + .map(|fs| { + Ok(FundingStream::ToAddress { + address: Address::from_str(&fs.address) + .context("invalid funding stream address in validators.json")?, + rate_bps: fs.rate_bps, + }) + }) + .collect::, anyhow::Error>>()?, + ) + .context("unable to construct funding streams from validators.json")?, + sequence_number: tv.sequence_number, + }) + } } impl TryFrom for genesis::Allocation { @@ -357,7 +556,7 @@ mod tests { "100000","udelegation_penumbravalid1t2hr2lj5n2jt3hftzjw3strjllnakc7jtw234d229x3zakhaqsqsg9yarw","penumbrav2t1wvjml4xqa70x3ypqa530npy8ygsftyjxc2wfgh4t7yftja7cfrlr7temqcerhnkd6g7qe75r7wg0ny9vvf4wcrd9rttvuhj9fy20yx630g26khmnxd2zvjnhm2t3wqu59e7nzk" "100000","upenumbra","penumbrav2t1wvjml4xqa70x3ypqa530npy8ygsftyjxc2wfgh4t7yftja7cfrlr7temqcerhnkd6g7qe75r7wg0ny9vvf4wcrd9rttvuhj9fy20yx630g26khmnxd2zvjnhm2t3wqu59e7nzk" "#; - let allos = parse_allocations(csv_content.as_bytes())?; + let allos = TestnetAllocation::from_reader(csv_content.as_bytes())?; let a1 = &allos[0]; assert!(a1.denom == "udelegation_penumbravalid1jzcc6vsm29am9ggs8z0d7s9jk9uf8tfrqg7hglc9ufs7r23nu5yqtw77ex"); @@ -377,8 +576,62 @@ mod tests { let csv_content = r#" "amount","denom","address"\n"100000","udelegation_penumbravalid1jzcc6vsm29am9ggs8z0d7s9jk9uf8tfrqg7hglc9ufs7r23nu5yqtw77ex","penumbrav2t1tntrwl92wmud955405mhduuvlxqksa00d2yqe3npjafvley64pal4sre3jcjhq5xjmrs56hk2fs8u648zcaarnz57rynqa0vtaxyd6rwev225lx4kku3lrjktrcacjyw5070nj"\n"100000","upenumbra","penumbrav2t1tntrwl92wmud955405mhduuvlxqksa00d2yqe3npjafvley64pal4sre3jcjhq5xjmrs56hk2fs8u648zcaarnz57rynqa0vtaxyd6rwev225lx4kku3lrjktrcacjyw5070nj"\n"100000","udelegation_penumbravalid1p2hfuch2p8rshyc90qa23gqk82s74tqcu3x2x3y5tfwpzth4vvrq2gv283","penumbrav2t1ctq4cm9fjewj790alfka634n2t32vh32vqnfufw2dpegw7c2a3lw9np2f4pcthl2w2ke4a32cdmmnurd95sjreu92vey3fwj9ccjz2hpudd9kz9xqlwqp39sly8knl0e2esqjw"\n"100000","upenumbra","penumbrav2t1ctq4cm9fjewj790alfka634n2t32vh32vqnfufw2dpegw7c2a3lw9np2f4pcthl2w2ke4a32cdmmnurd95sjreu92vey3fwj9ccjz2hpudd9kz9xqlwqp39sly8knl0e2esqjw"\n"100000","udelegation_penumbravalid182k8x46hg5vx3ez8ec58ze5yd6a3q4q3fkx45ddt5jahnzz0xyyqdtz7hc","penumbrav2t1ks2ee68kf96zs3p2af2pzcu7p36uep5gynwc38slvs8kpcyk0t0gdseww4aulntzaq9vurahmka99c4frpgehtteur29p5kt39a2qv0nd89etty36s55knlv7e98kl93p8farz"\n"100000","upenumbra","penumbrav2t1ks2ee68kf96zs3p2af2pzcu7p36uep5gynwc38slvs8kpcyk0t0gdseww4aulntzaq9vurahmka99c4frpgehtteur29p5kt39a2qv0nd89etty36s55knlv7e98kl93p8farz"\n"100000","udelegation_penumbravalid1t2hr2lj5n2jt3hftzjw3strjllnakc7jtw234d229x3zakhaqsqsg9yarw","penumbrav2t1wvjml4xqa70x3ypqa530npy8ygsftyjxc2wfgh4t7yftja7cfrlr7temqcerhnkd6g7qe75r7wg0ny9vvf4wcrd9rttvuhj9fy20yx630g26khmnxd2zvjnhm2t3wqu59e7nzk"\n"100000","upenumbra","penumbrav2t1wvjml4xqa70x3ypqa530npy8ygsftyjxc2wfgh4t7yftja7cfrlr7temqcerhnkd6g7qe75r7wg0ny9vvf4wcrd9rttvuhj9fy20yx630g26khmnxd2zvjnhm2t3wqu59e7nzk"\n "#; - let result = parse_allocations(csv_content.as_bytes()); + let result = TestnetAllocation::from_reader(csv_content.as_bytes()); assert!(result.is_err()); Ok(()) } + + #[test] + /// Generate a config suitable for local testing: no custom address information, no additional + /// validators at genesis. + fn generate_devnet_config() -> anyhow::Result<()> { + let testnet_config = TestnetConfig::generate( + "test-chain-1234", + None, + None, + None, + None, + None, + None, + None, + None, + None, + )?; + assert_eq!(testnet_config.name, "test-chain-1234"); + assert_eq!(testnet_config.genesis.validators.len(), 0); + // No external address template was given, so only 1 validator will be present. + assert_eq!(testnet_config.genesis.app_state.validators.len(), 1); + Ok(()) + } + + #[test] + /// Generate a config suitable for a public testnet: custom validators input file, + /// increasing the default validators from 1 -> 2. + fn generate_testnet_config() -> anyhow::Result<()> { + let ci_validators_filepath = PathBuf::from("../../../testnets/validators-ci.json"); + let testnet_config = TestnetConfig::generate( + "test-chain-4567", + None, + Some(String::from("validator.local")), + None, + None, + Some(ci_validators_filepath), + None, + None, + None, + None, + )?; + assert_eq!(testnet_config.name, "test-chain-4567"); + assert_eq!(testnet_config.genesis.validators.len(), 0); + assert_eq!(testnet_config.genesis.app_state.validators.len(), 2); + Ok(()) + } + + // #[test] + // fn testnet_validator_to_validator_conversion() -> anyhow::Result<()> { + // let tv = TestnetValidator::default(); + // let v: Validator = tv.try_into()?; + // assert!(v.website == ""); + // Ok(()) + // } } diff --git a/crates/bin/pd/src/testnet/join.rs b/crates/bin/pd/src/testnet/join.rs index 2c477ef829..e9e50a6993 100644 --- a/crates/bin/pd/src/testnet/join.rs +++ b/crates/bin/pd/src/testnet/join.rs @@ -6,7 +6,8 @@ use std::path::PathBuf; use tendermint_config::net::Address as TendermintAddress; use url::Url; -use crate::testnet::{generate_tm_config, parse_tm_address, write_configs, ValidatorKeys}; +use crate::testnet::config::{parse_tm_address, TestnetTendermintConfig}; +use crate::testnet::generate::TestnetValidator; /// Bootstrap a connection to a testnet, via a node on that testnet. /// Look up network peer info from the target node, and seed the tendermint @@ -21,7 +22,7 @@ pub async fn testnet_join( ) -> anyhow::Result<()> { let mut node_dir = output_dir; node_dir.push("node0"); - let genesis_url = node.join("/genesis")?; + let genesis_url = node.join("genesis")?; tracing::info!(?genesis_url, "fetching genesis"); // We need to download the genesis data and the node ID from the remote node. // TODO: replace with TendermintProxyServiceClient @@ -51,9 +52,9 @@ pub async fn testnet_join( } let new_peers = fetch_peers(&node).await?; peers.extend(new_peers); - tracing::info!(?peers); + tracing::info!(?peers, "Network peers for inclusion in generated configs"); - let tm_config = generate_tm_config( + let tm_config = TestnetTendermintConfig::new( node_name, peers, external_address, @@ -61,8 +62,8 @@ pub async fn testnet_join( Some(tm_p2p_bind), )?; - let vk = ValidatorKeys::generate(); - write_configs(node_dir, &vk, &genesis, tm_config)?; + let tv = TestnetValidator::default(); + tm_config.write_config(node_dir, &tv, &genesis)?; Ok(()) } @@ -94,7 +95,7 @@ pub async fn fetch_listen_address(tm_url: &Url) -> Option { // Next we'll look up the node_id, so we can assemble a self-authenticating // Tendermint Address, in the form of @. let node_id = client - .get(tm_url.join("/status").ok()?) + .get(tm_url.join("status").ok()?) .send() .await .ok()? @@ -124,7 +125,8 @@ pub async fn fetch_listen_address(tm_url: &Url) -> Option { /// addresses like `localhost` or `0.0.0.0`. pub async fn fetch_peers(tm_url: &Url) -> anyhow::Result> { let client = reqwest::Client::new(); - let net_info_url = tm_url.join("/net_info")?; + let net_info_url = tm_url.join("net_info")?; + tracing::debug!(%net_info_url, "Fetching peers of bootstrap node"); let net_info_peers = client .get(net_info_url) .send() @@ -137,8 +139,15 @@ pub async fn fetch_peers(tm_url: &Url) -> anyhow::Result> .cloned() .unwrap_or_default(); + if net_info_peers.len() == 0 { + tracing::warn!( + ?net_info_peers, + "Bootstrap node reported 0 peers; we'll have no way to get blocks" + ); + } let mut peers = Vec::new(); for raw_peer in net_info_peers { + tracing::debug!(?raw_peer, "Analyzing whether to include candidate peer"); let node_id: tendermint::node::Id = raw_peer .get("node_info") .and_then(|v| v.get("id")) @@ -151,10 +160,17 @@ pub async fn fetch_peers(tm_url: &Url) -> anyhow::Result> .and_then(|v| v.as_str()) .ok_or_else(|| { anyhow::anyhow!("Could not parse node_info.listen_addr from JSON response") - })?; + })? + // Depending on node config, there may or may not be a protocol prefix. + // Remove it so we can treat it as a SocketAddr when checking for internal/external. + .replace("tcp://", ""); // Filter out addresses that are obviously not external addresses. - if !address_could_be_external(listen_addr) { + if !address_could_be_external(&listen_addr) { + tracing::debug!( + ?listen_addr, + "Skipping candidate peer due to internal listener address" + ); continue; } @@ -197,7 +213,7 @@ fn address_could_be_external(address: &str) -> bool { pub fn parse_tm_address_listener(s: &str) -> Option { let re = regex::Regex::new(r"Listener\(.*@(tcp://)?(.*)\)").ok()?; let groups = re.captures(s).unwrap(); - let r: Option = groups.get(2).map_or(None, |m| Some(m.as_str().to_string())); + let r: Option = groups.get(2).map(|m| m.as_str().to_string()); match r { Some(t) => { // Haven't observed a local addr in Listener field, but let's make sure diff --git a/crates/core/component/chain/src/genesis/app_state.rs b/crates/core/component/chain/src/genesis/app_state.rs index 155c27b836..07b67c1fe5 100644 --- a/crates/core/component/chain/src/genesis/app_state.rs +++ b/crates/core/component/chain/src/genesis/app_state.rs @@ -88,3 +88,20 @@ impl TypeUrl for AppState { impl DomainType for AppState { type Proto = pb::GenesisAppState; } + +#[cfg(test)] +mod test { + use super::*; + /// Check that the default implementation of AppState contains zero validators, + /// requiring validators to be passed in out of band. N.B. there's also a + /// `validators` field in the [`tendermint::Genesis`] struct, which we don't use, + /// preferring the AppState definition instead. + #[test] + fn check_validator_defaults() -> anyhow::Result<()> { + let a = AppState { + ..Default::default() + }; + assert!(a.validators.len() == 0); + Ok(()) + } +} diff --git a/testnets/validators-ci.json b/testnets/validators-ci.json new file mode 100644 index 0000000000..ed94983fb8 --- /dev/null +++ b/testnets/validators-ci.json @@ -0,0 +1,66 @@ +[ + { + "name": "Penumbra Labs CI 1", + "website": "https://penumbra.zone", + "description": "This is a validator run by Penumbra Labs, using testnets as a public CI", + "funding_streams": [ + [ + 50, + "penumbrav2t1fcy6crf6u4r450k8y4nye43puxet2ytfh7s0dzxsxjk68czej9mp37xv49np0clv4dc8cwg4re0xfs79uwlfehnja4p0revmlek0drezxfse8spg3qc6gux6vyuzuuls6v6mmr" + ], + [ + 50, + "penumbrav2t13ahs2s8ms6q0utgetty3zflwteepg87gqm88sqqcdj2mjhhydkykwu6n7dk557x84aa9a6cqhdytw0zk33xjgmuedprrlunc86up6zps8juej9rpuuydjtk7jaxpmrw2a64mcf" + ], + [ + 50, + "penumbrav2t1uw03wyt49u7wm5wgu4nvkdt0v48fdaw5y4az4xlgmnp6ucs6th4xd0zg8wqxwndwfv286ktjwgemyhrxqu0d5qjf8dapr57l3k8yqs09vw9m5ywxsx9hjj2dj4qwnrl2qs6222" + ], + [ + 50, + "penumbrav2t1w6em8sdx0467ug9kk0s0sng254tqjfk9gglv6ff7dq2v8arwekevkjte9udzmsj9l83mz74747tj0a49w2vhecxj7ac4upr5c5pvjqhsy7dwn422m8dgdekt7y4lmad0fg04dr" + ], + [ + 50, + "penumbrav2t1jp4pryqqmh65pq8e7zwk6k2674vwhn4qqphxjk0vukxln0crmp2tdld0mhavuyrspwuajnsk5t5t33u2auxvheunr7qde4l068ez0euvtu08z7rwj6shlh64ndz0wvz7mfqdcd" + ], + [ + 50, + "penumbrav2t1hum845ches70c8kp8zfx7nerjwfe653hxsrpgwepwtspcp4jy6ytnxhe5kwn56sku684x6zzqcwp5ycrkee5mmg9kdl3jkr5lqn2xq3kqxvp4d7gwqdue5jznk2ter2t66mk4n" + ] + ], + "sequence_number": 0 + }, + { + "name": "Penumbra Labs CI 2", + "website": "https://penumbra.zone", + "description": "This is a validator run by Penumbra Labs, using testnets as a public CI", + "funding_streams": [ + [ + 50, + "penumbrav2t1fcy6crf6u4r450k8y4nye43puxet2ytfh7s0dzxsxjk68czej9mp37xv49np0clv4dc8cwg4re0xfs79uwlfehnja4p0revmlek0drezxfse8spg3qc6gux6vyuzuuls6v6mmr" + ], + [ + 50, + "penumbrav2t13ahs2s8ms6q0utgetty3zflwteepg87gqm88sqqcdj2mjhhydkykwu6n7dk557x84aa9a6cqhdytw0zk33xjgmuedprrlunc86up6zps8juej9rpuuydjtk7jaxpmrw2a64mcf" + ], + [ + 50, + "penumbrav2t1uw03wyt49u7wm5wgu4nvkdt0v48fdaw5y4az4xlgmnp6ucs6th4xd0zg8wqxwndwfv286ktjwgemyhrxqu0d5qjf8dapr57l3k8yqs09vw9m5ywxsx9hjj2dj4qwnrl2qs6222" + ], + [ + 50, + "penumbrav2t1w6em8sdx0467ug9kk0s0sng254tqjfk9gglv6ff7dq2v8arwekevkjte9udzmsj9l83mz74747tj0a49w2vhecxj7ac4upr5c5pvjqhsy7dwn422m8dgdekt7y4lmad0fg04dr" + ], + [ + 50, + "penumbrav2t1jp4pryqqmh65pq8e7zwk6k2674vwhn4qqphxjk0vukxln0crmp2tdld0mhavuyrspwuajnsk5t5t33u2auxvheunr7qde4l068ez0euvtu08z7rwj6shlh64ndz0wvz7mfqdcd" + ], + [ + 50, + "penumbrav2t1hum845ches70c8kp8zfx7nerjwfe653hxsrpgwepwtspcp4jy6ytnxhe5kwn56sku684x6zzqcwp5ycrkee5mmg9kdl3jkr5lqn2xq3kqxvp4d7gwqdue5jznk2ter2t66mk4n" + ] + ], + "sequence_number": 0 + } +]