From 2f958af80a734f98c84fc843b734b622bfdeab7e Mon Sep 17 00:00:00 2001 From: Tomas Tauber <2410580+tomtau@users.noreply.github.com> Date: Thu, 30 Jun 2022 16:35:10 +0800 Subject: [PATCH] validator: added a validator crate fixes #1134 --- .../features/1134-validator-crate.md | 2 + Cargo.toml | 3 +- validator/Cargo.toml | 35 ++ validator/examples/softsign.rs | 36 ++ validator/src/config.rs | 30 ++ validator/src/error.rs | 18 + validator/src/lib.rs | 13 + validator/src/server.rs | 463 ++++++++++++++++++ validator/src/signer.rs | 91 ++++ validator/src/state.rs | 118 +++++ 10 files changed, 808 insertions(+), 1 deletion(-) create mode 100644 .changelog/unreleased/features/1134-validator-crate.md create mode 100644 validator/Cargo.toml create mode 100644 validator/examples/softsign.rs create mode 100644 validator/src/config.rs create mode 100644 validator/src/error.rs create mode 100644 validator/src/lib.rs create mode 100644 validator/src/server.rs create mode 100644 validator/src/signer.rs create mode 100644 validator/src/state.rs diff --git a/.changelog/unreleased/features/1134-validator-crate.md b/.changelog/unreleased/features/1134-validator-crate.md new file mode 100644 index 000000000..76085b2e3 --- /dev/null +++ b/.changelog/unreleased/features/1134-validator-crate.md @@ -0,0 +1,2 @@ +- Added validator crate as per ADR-011 + ([#1134](https://github.com/informalsystems/tendermint-rs/issues/1134)) diff --git a/Cargo.toml b/Cargo.toml index 7b00a2bf6..8512c51e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,8 @@ members = [ "std-ext", "tendermint", "test", - "testgen" + "testgen", + "validator" ] exclude = [ diff --git a/validator/Cargo.toml b/validator/Cargo.toml new file mode 100644 index 000000000..e8dc144c8 --- /dev/null +++ b/validator/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "tendermint-validator" +version = "0.24.0-pre.2" +authors = ["Informal Systems "] +edition = "2021" +license = "Apache-2.0" +readme = "README.md" +categories = ["cryptography::cryptocurrencies", "network-programming"] +keywords = ["privval", "validator", "blockchain", "bft", "consensus", "tendermint"] +repository = "https://github.com/informalsystems/tendermint-rs" +description = """ + tendermint-validator provides a simple framework with which to build key management + for Tendermint validators. + """ + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[features] +default = ["flex-error/std", "flex-error/eyre_tracer"] + +[dependencies] +ed25519-consensus = { version = "1.2", default-features = false } +flex-error = { version = "0.4.4", default-features = false } +rand_core = { version = "0.6", default-features = false, features = ["std"] } +serde_json = "1" +tempfile = { version = "3.2.0" } +tendermint = { version = "0.24.0-pre.2", default-features = false, path = "../tendermint" } +tendermint-proto = { version = "0.24.0-pre.2", default-features = false, path = "../proto", features = ["grpc"] } +tokio = { version = "1", features = ["full"] } +tokio-stream = { version = "0.1", features = ["net"] } +tonic = { version = "0.7", features = ["tls", "transport"] } +tracing = { version = "0.1", default-features = false } + +[dev-dependencies] +tracing-subscriber = "0.2" diff --git a/validator/examples/softsign.rs b/validator/examples/softsign.rs new file mode 100644 index 000000000..19f20fb50 --- /dev/null +++ b/validator/examples/softsign.rs @@ -0,0 +1,36 @@ +// use std::collections::HashMap; + +// use ed25519_consensus::SigningKey; +// use tendermint::account::Id; +// use tendermint_validator::{ +// BasicServerConfig, FileStateProvider, GrpcSocket, SoftwareSigner, SoftwareSignerServer, +// }; + +use std::collections::HashMap; + +use tendermint::chain; +use tendermint_validator::{ + BasicServerConfig, FileStateProvider, GrpcSocket, KMSServer, SoftwareSigner, +}; +use tracing::Level; +use tracing_subscriber::FmtSubscriber; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let subscriber = FmtSubscriber::builder() + .with_max_level(Level::INFO) + .finish(); + let _ = tracing::subscriber::set_global_default(subscriber); + let mut providers = HashMap::new(); + let signer = SoftwareSigner::generate_ed25519(rand_core::OsRng); + let state_provider = FileStateProvider::new("/tmp/validator.json".into()) + .await + .unwrap(); + providers.insert( + chain::Id::try_from("test-chain-4bmabt").unwrap(), + (signer, state_provider), + ); + let config = BasicServerConfig::new(None, GrpcSocket::Unix("/tmp/validator.test".into())); + let server = KMSServer::new(providers, config).await.unwrap(); + server.serve().await +} diff --git a/validator/src/config.rs b/validator/src/config.rs new file mode 100644 index 000000000..f62f48664 --- /dev/null +++ b/validator/src/config.rs @@ -0,0 +1,30 @@ +//! Validator configuration. + +use std::{net::SocketAddr, path::PathBuf}; + +use tonic::transport::ServerTlsConfig; + +/// The common options for listening for gRPC connections. +#[derive(Debug, Clone)] +pub enum GrpcSocket { + /// the syntax in Tendermint config is "grpc://HOST:PORT" + Tcp(SocketAddr), + /// the syntax in Tendermint config is "grpc://unix:PATH" + Unix(PathBuf), +} + +/// The basic configuration for the gRPC server. +#[derive(Debug, Clone)] +pub struct BasicServerConfig { + /// The optional TLS configuration. + pub tls_config: Option, + /// The choice of a socket type to listen on. + pub socket: GrpcSocket, +} + +impl BasicServerConfig { + /// creates a basic config for the gRPC server + pub fn new(tls_config: Option, socket: GrpcSocket) -> Self { + Self { tls_config, socket } + } +} diff --git a/validator/src/error.rs b/validator/src/error.rs new file mode 100644 index 000000000..7a42c9c16 --- /dev/null +++ b/validator/src/error.rs @@ -0,0 +1,18 @@ +//! Validator errors (returned in a status via gRPC) + +use flex_error::{define_error, DetailOnly}; + +define_error! { + Error { + IoError{ + path: String, + } [DetailOnly] |e| { + format_args!("Error persisting {}", e.path) + }, + JsonError{ + path_or_msg: String, + } [DetailOnly] |e| { + format_args!("Error parsing or serializing validator state {}", e.path_or_msg) + }, + } +} diff --git a/validator/src/lib.rs b/validator/src/lib.rs new file mode 100644 index 000000000..a8468f31a --- /dev/null +++ b/validator/src/lib.rs @@ -0,0 +1,13 @@ +//! A framework for building key management solutions for [Tendermint] validators in Rust. +//! +//! [Tendermint]: https://tendermint.com +pub mod config; +pub mod error; +pub mod server; +pub mod signer; +pub mod state; + +pub use config::{BasicServerConfig, GrpcSocket}; +pub use server::KMSServer; +pub use signer::{SignerProvider, SoftwareSigner}; +pub use state::{FileStateProvider, ValidatorStateProvider}; diff --git a/validator/src/server.rs b/validator/src/server.rs new file mode 100644 index 000000000..3f0c55da8 --- /dev/null +++ b/validator/src/server.rs @@ -0,0 +1,463 @@ +//! Validator gRPC high-level server implementation + +use std::collections::HashMap; + +use tendermint::{ + block, chain, consensus, + proposal::{SignProposalRequest, SignedProposalResponse}, + public_key::{PubKeyRequest, PubKeyResponse}, + vote::{SignVoteRequest, SignedVoteResponse}, + PublicKey, Signature, +}; +use tendermint_proto::privval::{ + priv_validator_api_server::{PrivValidatorApi, PrivValidatorApiServer}, + PubKeyRequest as RawPubKeyRequest, PubKeyResponse as RawPubKeyResponse, RemoteSignerError, + SignProposalRequest as RawSignProposalRequest, SignVoteRequest as RawSignVoteRequest, + SignedProposalResponse as RawSignedProposalResponse, + SignedVoteResponse as RawSignedVoteResponse, +}; +use tokio::{net::UnixListener, sync::Mutex, time::Instant}; +use tokio_stream::wrappers::UnixListenerStream; +use tonic::{transport::Server, Request, Response, Status}; +use tracing::{debug, error, info}; + +use crate::{ + config::{BasicServerConfig, GrpcSocket}, + signer::{display_validator_info, SignerProvider}, + state::ValidatorStateProvider, +}; + +/// Validator gRPC high-level server implementation +#[derive(Debug)] +pub struct KMSServer { + /// Signer and state providers for individual chains. + /// Given the traits require mutable access to the state provider + /// across awaits, we use tokio's mutex here. + providers: Mutex>, + /// A cache of public keys loaded during the initialization. + pubkeys: HashMap, + /// Optional setting for the maximum height, + /// after which the server will stop signing for a particular network. + max_heights: HashMap, + /// The network configuration for the gRPC server. + config: BasicServerConfig, +} + +fn check_state( + chain_id: &chain::Id, + current: &consensus::State, + next: &consensus::State, +) -> Result<(), RemoteSignerError> { + if next > current { + Ok(()) + } else { + error!( + "[{}] attempted double sign at h/r/s: {} ({} != {})", + chain_id, + next, + current.block_id_prefix(), + next.block_id_prefix() + ); + Err(get_double_sign_error(&next.height)) + } +} + +impl KMSServer { + /// Creates a new server instance by loading all providers. + pub async fn new( + providers: HashMap, + config: BasicServerConfig, + ) -> Result { + info!("creating a new KMS server"); + let mut pubkeys = HashMap::new(); + for (chain_id, (signer, _)) in providers.iter() { + let pubkey = signer.load_pubkey().await?; + let (address, pubkeyb64) = display_validator_info(&pubkey); + info!("[{}] loaded a validator ID: {}", chain_id, address); + info!("[{}] public key: {}", chain_id, pubkeyb64); + pubkeys.insert(chain_id.clone(), pubkey); + } + + Ok(Self { + providers: Mutex::new(providers), + pubkeys, + max_heights: HashMap::new(), + config, + }) + } + + fn check_max_height(&self, chain_id: &chain::Id, new_height: block::Height) -> Result<(), ()> { + match self.max_heights.get(chain_id) { + Some(max_height) if new_height > *max_height => Err(()), + _ => Ok(()), + } + } +} + +impl< + S: SignerProvider + Sync + Send + 'static, + VS: ValidatorStateProvider + Sync + Send + 'static, + > KMSServer +{ + /// Based on the connection configuration, starts the gRPC server. + pub async fn serve(self) -> Result<(), Box> { + let mut server = Server::builder(); + let config = self.config.clone(); + if let Some(tls_config) = config.tls_config { + debug!("configuring TLS"); + server = server.tls_config(tls_config)?; + } + let router = server.add_service(PrivValidatorApiServer::new(self)); + match config.socket { + GrpcSocket::Tcp(addr) => { + info!( + "starting the Tendermint KMS gRPC server to listen on TCP: {}", + addr + ); + router.serve(addr).await?; + }, + GrpcSocket::Unix(path) => { + info!( + "starting the Tendermint KMS gRPC server to listen on Unix Domain Socket: {}", + path.display() + ); + let uds = UnixListener::bind(path)?; + let uds_stream = UnixListenerStream::new(uds); + router.serve_with_incoming(uds_stream).await?; + }, + } + Ok(()) + } +} + +async fn sign_and_persist_state< + S: SignerProvider + Sync + Send + 'static, + VS: ValidatorStateProvider + Sync + Send + 'static, +>( + chain_id: &chain::Id, + signer: &S, + state_provider: &mut VS, + new_state: consensus::State, + signable_bytes: Vec, +) -> Result { + let state = state_provider.load_state().await.map_err(|e| { + error!("[{}] failed to load the existing state: {}", chain_id, e); + get_state_not_found_error() + })?; + check_state(chain_id, &state, &new_state)?; + let started_at = Instant::now(); + let signature = signer.sign(&signable_bytes).await.map_err(|e| { + error!("[{}] failed to sign: {}", chain_id, e); + get_failed_to_sign_error() + })?; + info!( + "[{}] signed: {} at h/r/s {} ({} ms)", + chain_id, + new_state.block_id_prefix(), + new_state, + started_at.elapsed().as_millis(), + ); + if let Err(e) = state_provider.persist_state(&new_state).await { + error!("[{}] failed to persist the state: {}", chain_id, e); + } + Ok(signature) +} + +/// raw error types for the responses +fn get_invalid_chain_id_error(chain_id: &chain::Id) -> RemoteSignerError { + RemoteSignerError { + code: 1, + description: format!("invalid chain id: {}", chain_id), + } +} + +/// raw error types for the responses +fn get_double_sign_error(height: &block::Height) -> RemoteSignerError { + RemoteSignerError { + code: 2, + description: format!("double signing requested at height: {}", height), + } +} + +/// raw error types for the responses +fn get_state_not_found_error() -> RemoteSignerError { + RemoteSignerError { + code: 3, + description: "existing state failed to load (internal error)".to_owned(), + } +} + +/// raw error types for the responses +fn get_failed_to_sign_error() -> RemoteSignerError { + RemoteSignerError { + code: 4, + description: "signer failed to sign (internal error)".to_owned(), + } +} + +#[tonic::async_trait] +impl< + S: SignerProvider + Sync + Send + 'static, + VS: ValidatorStateProvider + Sync + Send + 'static, + > PrivValidatorApi for KMSServer +{ + async fn get_pub_key( + &self, + request: Request, + ) -> Result, Status> { + let req = PubKeyRequest::try_from(request.into_inner()) + .map_err(|e| Status::invalid_argument(e.to_string()))?; + debug!("[{}] received a public key request", req.chain_id); + let resp = match self.pubkeys.get(&req.chain_id) { + Some(pubkey) => PubKeyResponse { + pub_key: Some(*pubkey), + error: None, + }, + None => { + error!("[{}] no public key found", req.chain_id); + PubKeyResponse { + pub_key: None, + error: Some(get_invalid_chain_id_error(&req.chain_id)), + } + }, + }; + Ok(Response::new(resp.into())) + } + async fn sign_vote( + &self, + request: Request, + ) -> Result, Status> { + let req = SignVoteRequest::try_from(request.into_inner()) + .map_err(|e| Status::invalid_argument(e.to_string()))?; + debug!( + "[{}] received a vote signing request: {:?}", + req.chain_id, req + ); + self.check_max_height(&req.chain_id, req.vote.height) + .map_err(|_| Status::failed_precondition("max height exceeded"))?; + let mut providers = self.providers.lock().await; + let resp = if let Some((signer, state_provider)) = providers.get_mut(&req.chain_id) { + let new_state = (&req).into(); + let signable_bytes = req + .vote + .to_signable_vec(req.chain_id.clone()) + .map_err(|e| Status::internal(e.to_string()))?; + match sign_and_persist_state( + &req.chain_id, + signer, + state_provider, + new_state, + signable_bytes, + ) + .await + { + Ok(signature) => { + let mut vote = req.vote.clone(); + vote.signature = Some(signature); + SignedVoteResponse { + vote: Some(vote), + error: None, + } + }, + Err(err) => SignedVoteResponse { + vote: None, + error: Some(err), + }, + } + } else { + error!("[{}] no signer found", req.chain_id); + SignedVoteResponse { + vote: None, + error: Some(get_invalid_chain_id_error(&req.chain_id)), + } + }; + Ok(Response::new(resp.into())) + } + async fn sign_proposal( + &self, + request: Request, + ) -> Result, Status> { + let req = SignProposalRequest::try_from(request.into_inner()) + .map_err(|e| Status::invalid_argument(e.to_string()))?; + debug!( + "[{}] received a proposal signing request: {:?}", + req.chain_id, req + ); + self.check_max_height(&req.chain_id, req.proposal.height) + .map_err(|_| Status::failed_precondition("max height exceeded"))?; + let mut providers = self.providers.lock().await; + let resp = if let Some((signer, state_provider)) = providers.get_mut(&req.chain_id) { + let new_state = (&req).into(); + let signable_bytes = req + .proposal + .to_signable_vec(req.chain_id.clone()) + .map_err(|e| Status::internal(e.to_string()))?; + match sign_and_persist_state( + &req.chain_id, + signer, + state_provider, + new_state, + signable_bytes, + ) + .await + { + Ok(signature) => { + let mut proposal = req.proposal.clone(); + proposal.signature = Some(signature); + SignedProposalResponse { + proposal: Some(proposal), + error: None, + } + }, + Err(err) => SignedProposalResponse { + proposal: None, + error: Some(err), + }, + } + } else { + error!("[{}] no signer found", req.chain_id); + SignedProposalResponse { + proposal: None, + error: Some(get_invalid_chain_id_error(&req.chain_id)), + } + }; + Ok(Response::new(resp.into())) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use tendermint::{block::Height, chain, consensus, proposal::Type, Proposal, Vote}; + use tendermint_proto::privval::priv_validator_api_server::PrivValidatorApi; + use tracing::Level; + use tracing_subscriber::FmtSubscriber; + + use crate::{BasicServerConfig, GrpcSocket, KMSServer, SoftwareSigner, ValidatorStateProvider}; + + const CHAIN_ID: &str = "test"; + const CHAIN_ID2: &str = "test2"; + + #[derive(Default)] + pub struct MockStateProvider { + last_state: consensus::State, + } + + #[tonic::async_trait] + impl ValidatorStateProvider for MockStateProvider { + type E = crate::error::Error; + + async fn load_state(&self) -> Result { + Ok(self.last_state.clone()) + } + + async fn persist_state(&mut self, new_state: &consensus::State) -> Result<(), Self::E> { + self.last_state = new_state.clone(); + Ok(()) + } + } + + async fn test_setup() -> KMSServer { + let subscriber = FmtSubscriber::builder() + .with_max_level(Level::TRACE) + .finish(); + let _ = tracing::subscriber::set_global_default(subscriber); + let mut providers = HashMap::new(); + let signer = SoftwareSigner::generate_ed25519(rand_core::OsRng); + let state_provider = MockStateProvider::default(); + providers.insert( + chain::Id::try_from(CHAIN_ID).unwrap(), + (signer, state_provider), + ); + let config = BasicServerConfig::new(None, GrpcSocket::Unix("/tmp/test.socket".into())); + KMSServer::new(providers, config).await.unwrap() + } + + #[tokio::test] + pub async fn test_get_pubkey() { + let server = test_setup().await; + let req = tonic::Request::new(super::RawPubKeyRequest { + chain_id: CHAIN_ID.to_string(), + }); + let resp = server.get_pub_key(req).await.unwrap(); + let resp = resp.into_inner(); + assert!(resp.pub_key.is_some() && resp.error.is_none()); + let req2 = tonic::Request::new(super::RawPubKeyRequest { + chain_id: CHAIN_ID2.to_string(), + }); + let resp2 = server.get_pub_key(req2).await.unwrap(); + let resp2 = resp2.into_inner(); + assert!(resp2.pub_key.is_none() && resp2.error.is_some()); + assert_eq!(resp2.error.unwrap().code, 1); + } + + #[tokio::test] + pub async fn test_sign_vote() { + let server = test_setup().await; + let vote = Vote::default(); + let req = tonic::Request::new(super::RawSignVoteRequest { + chain_id: CHAIN_ID.to_string(), + vote: Some(vote.clone().into()), + }); + let resp = server.sign_vote(req).await.unwrap(); + let resp = resp.into_inner(); + assert!(resp.vote.is_some() && resp.error.is_none()); + let req2 = tonic::Request::new(super::RawSignVoteRequest { + chain_id: CHAIN_ID2.to_string(), + vote: Some(vote.into()), + }); + let resp2 = server.sign_vote(req2).await.unwrap(); + let resp2 = resp2.into_inner(); + assert!(resp2.vote.is_none() && resp2.error.is_some()); + assert_eq!(resp2.error.unwrap().code, 1); + } + + #[tokio::test] + pub async fn test_sign_proposal() { + let server = test_setup().await; + let proposal = Proposal { + msg_type: Type::Proposal, + height: Height::increment(Default::default()), + round: Default::default(), + pol_round: None, + block_id: None, + timestamp: None, + signature: None, + }; + let req = tonic::Request::new(super::RawSignProposalRequest { + chain_id: CHAIN_ID.to_string(), + proposal: Some(proposal.clone().into()), + }); + let resp = server.sign_proposal(req).await.unwrap(); + let resp = resp.into_inner(); + assert!(resp.proposal.is_some() && resp.error.is_none()); + let req2 = tonic::Request::new(super::RawSignProposalRequest { + chain_id: CHAIN_ID2.to_string(), + proposal: Some(proposal.into()), + }); + let resp2 = server.sign_proposal(req2).await.unwrap(); + let resp2 = resp2.into_inner(); + assert!(resp2.proposal.is_none() && resp2.error.is_some()); + assert_eq!(resp2.error.unwrap().code, 1); + } + + #[tokio::test] + pub async fn test_double_sign() { + let server = test_setup().await; + let vote = Vote::default(); + let inner_req = super::RawSignVoteRequest { + chain_id: CHAIN_ID.to_string(), + vote: Some(vote.clone().into()), + }; + let req = tonic::Request::new(inner_req.clone()); + let resp = server.sign_vote(req).await.unwrap(); + let resp = resp.into_inner(); + assert!(resp.vote.is_some() && resp.error.is_none()); + let req2 = tonic::Request::new(inner_req); + let resp2 = server.sign_vote(req2).await.unwrap(); + let resp2 = resp2.into_inner(); + assert!(resp2.vote.is_none() && resp2.error.is_some()); + assert_eq!(resp2.error.unwrap().code, 2); + } +} diff --git a/validator/src/signer.rs b/validator/src/signer.rs new file mode 100644 index 000000000..0a7018bbb --- /dev/null +++ b/validator/src/signer.rs @@ -0,0 +1,91 @@ +//! Signing-related interface and the sample software-only implementation. + +use ed25519_consensus::SigningKey; +use rand_core::{CryptoRng, RngCore}; +use tendermint::{account, PrivateKey, PublicKey, Signature}; + +use crate::error::Error; + +/// The trait for different signing backends +/// (HSMs, TEEs, software-only, remote services etc.) +#[tonic::async_trait] +pub trait SignerProvider { + type E: std::error::Error; + async fn sign(&self, signable_bytes: &[u8]) -> Result; + async fn load_pubkey(&self) -> Result; +} + +/// A helper function that will convert the validator public key +/// into textual forms that are needed in different operational contexts +/// (genesis, sending a validator creation transaction, etc.). +/// The returned textual components are: +/// 1. "address": hexadecimal string of 20 bytes from the public key hash +/// 2. "public key": a json-encoded representation of the public key type +/// and its value as a base64-encoded string +pub fn display_validator_info(pubkey: &PublicKey) -> (String, String) { + let address = account::Id::from(*pubkey); + // the `pubkey` is expected to be valid, so this shouldn't fail + let pubkeyb64json = serde_json::to_string(pubkey).expect("pubkey to base64"); + (format!("{}", address), pubkeyb64json) +} + +/// The default software-only implementation of [`SignerProvider`]. +/// (Not recommended for production use, but it is useful for testing +/// or in combination with additional isolation from the host system, e.g. TEE.) +pub struct SoftwareSigner { + secret_key: PrivateKey, +} + +impl SoftwareSigner { + /// The default constructor + pub fn new(secret_key: PrivateKey) -> Self { + Self { secret_key } + } + + /// Generate a new random private key. + pub fn generate_ed25519(rng: R) -> Self { + let key = SigningKey::new(rng); + let sk = PrivateKey::Ed25519(key); + Self::new(sk) + } +} + +#[tonic::async_trait] +impl SignerProvider for SoftwareSigner { + type E = Error; + async fn sign(&self, signable_bytes: &[u8]) -> Result { + Ok(self.secret_key.sign(signable_bytes)) + } + + async fn load_pubkey(&self) -> Result { + Ok(self.secret_key.public_key()) + } +} + +#[cfg(test)] +mod test { + use ed25519_consensus::SigningKey; + use tendermint::PublicKey; + + use crate::{SignerProvider, SoftwareSigner}; + + #[test] + pub fn test_display_validator_info() { + let signing_key = SigningKey::from([2u8; 32]); + let ver_key = signing_key.verification_key(); + let pubkey = PublicKey::from(ver_key); + let (address, pubkeyb64) = super::display_validator_info(&pubkey); + assert_eq!(address, "6A3803D5F059902A1C6DAFBC9BA4729212F7CAAC"); + assert_eq!(pubkeyb64, "{\"type\":\"tendermint/PubKeyEd25519\",\"value\":\"gTl3Dqh9F19Wo1Rmw0x+zMuNipG07jeiXfYPW4/Js5Q=\"}"); + } + + #[tokio::test] + pub async fn test_generate_sign() { + let rng = rand_core::OsRng; + let signer = SoftwareSigner::generate_ed25519(rng); + let signable_bytes = b"test message"; + let signature = signer.sign(signable_bytes).await.expect("sign"); + let pubkey = signer.load_pubkey().await.expect("pubkey"); + assert!(pubkey.verify(signable_bytes, &signature).is_ok()); + } +} diff --git a/validator/src/state.rs b/validator/src/state.rs new file mode 100644 index 000000000..e292f0387 --- /dev/null +++ b/validator/src/state.rs @@ -0,0 +1,118 @@ +//! Validator state-related interface and the sample file-based implementation. + +use std::{io::Write, path::PathBuf}; + +use tempfile::NamedTempFile; +use tendermint::consensus; +use tracing::debug; + +use crate::error::Error; + +/// The trait for the validator state storage. +/// (file, external DB, monotonic CPU counters...) +#[tonic::async_trait] +pub trait ValidatorStateProvider { + type E: std::error::Error; + async fn load_state(&self) -> Result; + async fn persist_state(&mut self, new_state: &consensus::State) -> Result<(), Self::E>; +} + +/// The default file-based implementation of [`ValidatorStateProvider`]. +pub struct FileStateProvider { + state_file_path: PathBuf, + last_state: consensus::State, +} + +impl FileStateProvider { + pub async fn new(state_file_path: PathBuf) -> Result { + match tokio::fs::read_to_string(&state_file_path).await { + Ok(state_json) => { + let consensus_state: consensus::State = serde_json::from_str(&state_json) + .map_err(|e| Error::json_error(state_file_path.display().to_string(), e))?; + + Ok(Self { + state_file_path, + last_state: consensus_state, + }) + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let consensus_state = consensus::State { + height: 0u32.into(), + ..Default::default() + }; + let mut provider = Self { + state_file_path, + last_state: consensus_state.clone(), + }; + provider.persist_state(&consensus_state).await?; + Ok(provider) + }, + Err(e) => Err(Error::io_error(state_file_path.display().to_string(), e)), + } + } +} + +#[tonic::async_trait] +impl ValidatorStateProvider for FileStateProvider { + type E = Error; + async fn load_state(&self) -> Result { + Ok(self.last_state.clone()) + } + + async fn persist_state(&mut self, new_state: &consensus::State) -> Result<(), Error> { + debug!( + "writing new consensus state to {}: {:?}", + self.state_file_path.display(), + &new_state + ); + + let json = serde_json::to_string(&new_state) + .map_err(|e| Error::json_error(self.state_file_path.display().to_string(), e))?; + + let state_file_dir = self.state_file_path.parent().unwrap_or_else(|| { + panic!("state file cannot be root directory"); + }); + + let mut state_file = NamedTempFile::new_in(state_file_dir) + .map_err(|e| Error::io_error(self.state_file_path.display().to_string(), e))?; + state_file + .write_all(json.as_bytes()) + .map_err(|e| Error::io_error(self.state_file_path.display().to_string(), e))?; + state_file + .persist(&self.state_file_path) + .map_err(|e| Error::io_error(self.state_file_path.display().to_string(), e.error))?; + + debug!( + "successfully wrote new consensus state to {}", + self.state_file_path.display(), + ); + + self.last_state = new_state.clone(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + + use crate::{FileStateProvider, ValidatorStateProvider}; + + #[tokio::test] + pub async fn test_file_persistence() { + let tf = tempfile::TempDir::new().expect("temp dir"); + let path = tf.path().join("validator.json"); + let mut provider = FileStateProvider::new(path.clone()) + .await + .expect("file provider"); + let mut state = provider.load_state().await.expect("load state 1"); + state.height = state.height.increment(); + provider.persist_state(&state).await.expect("persist state"); + let state2 = provider.load_state().await.expect("load state 2"); + assert_eq!(state, state2); + let provider = FileStateProvider::new(path.clone()) + .await + .expect("file provider"); + let state3 = provider.load_state().await.expect("load state 3"); + assert_eq!(state, state3); + } +}