use std::env;
use std::net::SocketAddr;
use std::path::Path;
use anyhow::{Context, Result};
use camino::Utf8PathBuf;
use clap::Parser;
use directories::ProjectDirs;
use penumbra_custody::policy::{AuthPolicy, PreAuthorizationPolicy};
use penumbra_custody::soft_kms::{self, SoftKms};
use penumbra_keys::keys::{SeedPhrase, SpendKey};
use penumbra_keys::FullViewingKey;
use penumbra_proto::{
core::app::v1alpha1::{
query_service_client::QueryServiceClient as AppQueryServiceClient, ChainParametersRequest,
},
custody::v1alpha1::custody_protocol_service_server::CustodyProtocolServiceServer,
view::v1alpha1::view_protocol_service_server::ViewProtocolServiceServer,
};
use penumbra_view::{Storage, ViewService};
use serde::{Deserialize, Serialize};
use serde_with::{serde_as, DisplayFromStr};
use std::fs;
use std::fs::File;
use std::io::{self, BufRead, Write};
use std::str::FromStr;
use tonic::transport::Server;
use url::Url;
mod proxy;
pub use proxy::{ObliviousQueryProxy, SpecificQueryProxy, TendermintProxyProxy};
#[serde_as]
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct PclientdConfig {
#[serde_as(as = "DisplayFromStr")]
pub full_viewing_key: FullViewingKey,
pub grpc_url: Url,
pub bind_addr: SocketAddr,
pub kms_config: Option<soft_kms::Config>,
}
impl PclientdConfig {
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
let contents = std::fs::read_to_string(path)?;
Ok(toml::from_str(&contents)?)
}
pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let contents = toml::to_string_pretty(&self)?;
std::fs::write(path, contents)?;
Ok(())
}
}
fn default_home() -> Utf8PathBuf {
let path = ProjectDirs::from("zone", "penumbra", "pclientd")
.expect("Failed to get platform data dir")
.data_dir()
.to_path_buf();
Utf8PathBuf::from_path_buf(path).expect("Platform default data dir was not UTF-8")
}
#[derive(Debug, Parser)]
#[clap(
name = "pclientd",
about = "The Penumbra view daemon.",
version = env!("VERGEN_GIT_SEMVER"),
)]
pub struct Opt {
#[clap(subcommand)]
pub cmd: Command,
#[clap(long, default_value_t = default_home(), env = "PENUMBRA_PCLIENTD_HOME")]
pub home: Utf8PathBuf,
}
#[derive(Debug, clap::Subcommand)]
pub enum Command {
Init {
#[clap(long, display_order = 100)]
view: Option<String>,
#[clap(long, display_order = 200)]
custody: Option<String>,
#[clap(
long,
display_order = 900,
default_value = "https://grpc.testnet.penumbra.zone",
parse(try_from_str = Url::parse)
)]
grpc_url: Url,
#[clap(long, display_order = 900, default_value = "127.0.0.1:8081")]
bind_addr: SocketAddr,
},
Start {},
Reset {},
}
impl Opt {
fn config_path(&self) -> Utf8PathBuf {
let mut path = self.home.clone();
path.push("config.toml");
path
}
fn sqlite_path(&self) -> Utf8PathBuf {
let mut path = self.home.clone();
path.push("pclientd-db.sqlite");
path
}
fn check_home_nonempty(&self) -> Result<()> {
if self.home.exists() {
if !self.home.is_dir() {
return Err(anyhow::anyhow!(
"The home directory {:?} is not a directory.",
self.home
));
}
let mut entries = fs::read_dir(&self.home)?.peekable();
if entries.peek().is_some() {
return Err(anyhow::anyhow!(
"The home directory {:?} is not empty, refusing to overwrite it",
self.home
));
}
} else {
fs::create_dir_all(&self.home)?;
}
Ok(())
}
async fn init_sqlite(&self, fvk: &FullViewingKey, grpc_url: &Url) -> Result<Storage> {
let mut client = AppQueryServiceClient::connect(grpc_url.to_string()).await?;
let params = client
.chain_parameters(tonic::Request::new(ChainParametersRequest {
chain_id: String::new(),
}))
.await?
.into_inner()
.try_into()?;
Storage::initialize(Some(self.sqlite_path()), fvk.clone(), params).await
}
async fn load_or_init_sqlite(&self, fvk: &FullViewingKey, grpc_url: &Url) -> Result<Storage> {
if self.sqlite_path().exists() {
Ok(Storage::load(self.sqlite_path()).await?)
} else {
self.init_sqlite(fvk, grpc_url).await
}
}
pub async fn exec(self) -> Result<()> {
let opt = self;
match &opt.cmd {
Command::Reset {} => {
if opt.sqlite_path().exists() {
fs::remove_file(opt.sqlite_path())?;
println!("Deleted local storage at: {:?}", opt.sqlite_path());
} else {
println!("No local storage at: {:?} (have you started pclientd, so it would have data to store?)", opt.sqlite_path());
}
Ok(())
}
Command::Init {
view,
custody,
grpc_url,
bind_addr,
} => {
opt.check_home_nonempty()?;
let seed_phrase = match custody {
None => None,
Some(seed_phrase) => {
if seed_phrase == "-" {
println!("Enter your seed phrase to enable pclientd custody mode: ");
let stdin = io::stdin();
let line = stdin
.lock()
.lines()
.next()
.expect("There was no next line.")
.expect("The line could not be read.");
Some(line)
} else {
Some(seed_phrase.clone())
}
}
};
let (spend_key, full_viewing_key) = match (seed_phrase, view) {
(Some(seed_phrase), None) => {
let spend_key = SpendKey::from_seed_phrase_bip39(
SeedPhrase::from_str(seed_phrase.as_str())?,
0,
);
let full_viewing_key = spend_key.full_viewing_key().clone();
(Some(spend_key), full_viewing_key)
}
(None, Some(view)) => (None, view.parse()?),
(None, None) => {
return Err(anyhow::anyhow!(
"Must provide either a seed phrase or a full viewing key."
))
}
(Some(_), Some(_)) => {
return Err(anyhow::anyhow!(
"Cannot provide both a seed phrase and a full viewing key."
))
}
};
println!(
"Initializing configuration at: {:?}",
fs::canonicalize(&opt.home)?
);
let kms_config: Option<soft_kms::Config> = spend_key.map(|spend_key| {
let pak = ed25519_consensus::SigningKey::new(rand_core::OsRng);
let pvk = pak.verification_key();
let auth_policy = vec![
AuthPolicy::DestinationAllowList {
allowed_destination_addresses: vec![
spend_key
.incoming_viewing_key()
.payment_address(Default::default())
.0,
],
},
AuthPolicy::OnlyIbcRelay,
AuthPolicy::PreAuthorization(PreAuthorizationPolicy::Ed25519 {
required_signatures: 1,
allowed_signers: vec![pvk],
}),
];
soft_kms::Config {
spend_key,
auth_policy,
}
});
let client_config = PclientdConfig {
kms_config,
full_viewing_key,
grpc_url: grpc_url.clone(),
bind_addr: bind_addr.clone(),
};
let encoded = toml::to_string_pretty(&client_config)
.expect("able to convert client config to toml string");
let config_file_path = &mut opt.home.clone();
config_file_path.push("config.toml");
let mut config_file = File::create(&config_file_path)?;
config_file.write_all(encoded.as_bytes())?;
Ok(())
}
Command::Start {} => {
let config = PclientdConfig::load(opt.config_path()).context(
"Failed to load pclientd config file. Have you run `pclientd init` with a FVK?",
)?;
tracing::info!(?opt.home, ?config.bind_addr, %config.grpc_url, "starting pclientd");
let storage = opt
.load_or_init_sqlite(&config.full_viewing_key, &config.grpc_url)
.await?;
let proxy_channel =
tonic::transport::Channel::from_shared(config.grpc_url.to_string())
.expect("this is a valid address")
.connect()
.await?;
let oblivious_query_proxy = ObliviousQueryProxy(proxy_channel.clone());
let specific_query_proxy = SpecificQueryProxy(proxy_channel.clone());
let tendermint_proxy_proxy = TendermintProxyProxy(proxy_channel.clone());
let view_service = ViewProtocolServiceServer::new(
ViewService::new(storage, config.grpc_url).await?,
);
let custody_service = config.kms_config.as_ref().map(|kms_config| {
CustodyProtocolServiceServer::new(SoftKms::new(
kms_config.spend_key.clone().into(),
))
});
let server = Server::builder()
.accept_http1(true)
.add_service(tonic_web::enable(view_service))
.add_optional_service(custody_service.map(|s| tonic_web::enable(s)))
.add_service(tonic_web::enable(oblivious_query_proxy))
.add_service(tonic_web::enable(specific_query_proxy))
.add_service(tonic_web::enable(tendermint_proxy_proxy))
.add_service(tonic_web::enable(
tonic_reflection::server::Builder::configure()
.register_encoded_file_descriptor_set(
penumbra_proto::FILE_DESCRIPTOR_SET,
)
.build()
.with_context(|| "could not configure grpc reflection service")?,
))
.serve(config.bind_addr.clone());
tokio::spawn(server).await??;
Ok(())
}
}
}
}