1#![cfg_attr(docsrs, feature(doc_auto_cfg))]
3
4use std::io::IsTerminal;
5use std::io::Read;
6use std::net::SocketAddr;
7use std::path::Path;
8
9use anyhow::{Context, Result};
10use camino::Utf8PathBuf;
11use clap::Parser;
12use directories::ProjectDirs;
13use penumbra_sdk_custody::policy::{AuthPolicy, PreAuthorizationPolicy};
14use penumbra_sdk_custody::soft_kms::{self, SoftKms};
15use penumbra_sdk_keys::keys::{Bip44Path, SeedPhrase, SpendKey};
16use penumbra_sdk_keys::FullViewingKey;
17use penumbra_sdk_proto::{
18 core::app::v1::{
19 query_service_client::QueryServiceClient as AppQueryServiceClient, AppParametersRequest,
20 },
21 custody::v1::custody_service_server::CustodyServiceServer,
22 view::v1::view_service_server::ViewServiceServer,
23};
24use penumbra_sdk_view::{Storage, ViewServer};
25use rpassword::prompt_password;
26use serde::{Deserialize, Serialize};
27use serde_with::{serde_as, DisplayFromStr};
28
29use std::fs;
30use std::fs::File;
31use std::io::Write;
32use std::str::FromStr;
33use tonic::transport::Server;
34use url::Url;
35
36mod proxy;
37pub use proxy::{
38 AppQueryProxy, ChainQueryProxy, CompactBlockQueryProxy, DexQueryProxy, DexSimulationProxy,
39 GovernanceQueryProxy, SctQueryProxy, ShieldedPoolQueryProxy, StakeQueryProxy,
40 TendermintProxyProxy,
41};
42
43use crate::proxy::FeeQueryProxy;
44
45#[serde_as]
46#[derive(Serialize, Deserialize, Clone, Debug)]
47pub struct PclientdConfig {
48 #[serde_as(as = "DisplayFromStr")]
50 pub full_viewing_key: FullViewingKey,
51 pub grpc_url: Url,
53 pub bind_addr: SocketAddr,
55 pub kms_config: Option<soft_kms::Config>,
57}
58
59impl PclientdConfig {
60 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
61 let contents = std::fs::read_to_string(path)?;
62 Ok(toml::from_str(&contents)?)
63 }
64
65 pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
66 let contents = toml::to_string_pretty(&self)?;
67 std::fs::write(path, contents)?;
68 Ok(())
69 }
70}
71
72pub fn default_home() -> Utf8PathBuf {
73 let path = ProjectDirs::from("zone", "penumbra", "pclientd")
74 .expect("Failed to get platform data dir")
75 .data_dir()
76 .to_path_buf();
77 Utf8PathBuf::from_path_buf(path).expect("Platform default data dir was not UTF-8")
78}
79
80#[derive(Debug, Parser)]
81#[clap(name = "pclientd", about = "The Penumbra view daemon.", version)]
82pub struct Opt {
83 #[clap(subcommand)]
85 pub cmd: Command,
86 #[clap(long, default_value_t = default_home(), env = "PENUMBRA_PCLIENTD_HOME")]
88 pub home: Utf8PathBuf,
89}
90
91#[derive(Debug, clap::Subcommand)]
92pub enum Command {
93 Init {
101 #[clap(long, display_order = 100)]
105 view: bool,
106 #[clap(
108 long,
109 display_order = 900,
110 parse(try_from_str = Url::parse)
111 )]
112 grpc_url: Url,
113 #[clap(long, display_order = 900, default_value = "127.0.0.1:8081")]
115 bind_addr: SocketAddr,
116 },
117 Start {},
119 Reset {},
121}
122
123impl Opt {
124 fn config_path(&self) -> Utf8PathBuf {
125 let mut path = self.home.clone();
126 path.push("config.toml");
127 path
128 }
129
130 fn sqlite_path(&self) -> Utf8PathBuf {
131 let mut path = self.home.clone();
132 path.push("pclientd-db.sqlite");
133 path
134 }
135
136 fn check_home_nonempty(&self) -> Result<()> {
137 if self.home.exists() {
138 if !self.home.is_dir() {
139 return Err(anyhow::anyhow!(
140 "The home directory {:?} is not a directory.",
141 self.home
142 ));
143 }
144 let mut entries = fs::read_dir(&self.home)?.peekable();
145 if entries.peek().is_some() {
146 return Err(anyhow::anyhow!(
147 "The home directory {:?} is not empty, refusing to overwrite it",
148 self.home
149 ));
150 }
151 } else {
152 fs::create_dir_all(&self.home)?;
153 }
154 Ok(())
155 }
156
157 async fn init_sqlite(&self, fvk: &FullViewingKey, grpc_url: &Url) -> Result<Storage> {
158 let mut client = AppQueryServiceClient::connect(grpc_url.to_string()).await?;
160
161 let params = client
162 .app_parameters(tonic::Request::new(AppParametersRequest {}))
163 .await?
164 .into_inner()
165 .try_into()?;
166
167 Storage::initialize(Some(self.sqlite_path()), fvk.clone(), params).await
168 }
169
170 async fn load_or_init_sqlite(&self, fvk: &FullViewingKey, grpc_url: &Url) -> Result<Storage> {
171 if self.sqlite_path().exists() {
172 Ok(Storage::load(self.sqlite_path()).await?)
173 } else {
174 self.init_sqlite(fvk, grpc_url).await
175 }
176 }
177
178 fn prompt_for_password(&self, msg: &str) -> Result<String> {
180 let mut password = String::new();
181 if std::io::stdin().is_terminal() {
185 password = prompt_password(msg)?;
186 } else {
187 while let Ok(n_bytes) = std::io::stdin().lock().read_to_string(&mut password) {
188 if n_bytes == 0 {
189 break;
190 }
191 password = password.trim().to_string();
192 }
193 }
194 Ok(password)
195 }
196
197 pub async fn exec(self) -> Result<()> {
198 let opt = self;
199 match &opt.cmd {
200 Command::Reset {} => {
201 if opt.sqlite_path().exists() {
202 fs::remove_file(opt.sqlite_path())?;
203 println!("Deleted local storage at: {:?}", opt.sqlite_path());
204 } else {
205 println!("No local storage at: {:?} (have you started pclientd, so it would have data to store?)", opt.sqlite_path());
206 }
207
208 Ok(())
209 }
210 Command::Init {
211 view,
212 grpc_url,
213 bind_addr,
214 } => {
215 opt.check_home_nonempty()?;
217
218 let key_material: String;
220 let spend_key: Option<SpendKey>;
221 let full_viewing_key: FullViewingKey;
222
223 if *view {
225 key_material = opt
226 .prompt_for_password("Enter full viewing key: ")?
227 .to_owned();
228 full_viewing_key = key_material.parse()?;
229 spend_key = None;
230 } else {
232 key_material = opt
233 .prompt_for_password(
234 "Enter your seed phrase to enable pclientd custody mode: ",
235 )?
236 .to_owned();
237 let sk = SpendKey::from_seed_phrase_bip44(
238 SeedPhrase::from_str(key_material.as_str())?,
239 &Bip44Path::new(0),
240 );
241 full_viewing_key = sk.full_viewing_key().clone();
242 spend_key = Some(sk);
243 }
244
245 println!(
246 "Initializing configuration at: {:?}",
247 fs::canonicalize(&opt.home)?
248 );
249
250 let kms_config: Option<soft_kms::Config> = spend_key.map(|spend_key| {
252 let pak = ed25519_consensus::SigningKey::new(rand_core::OsRng);
255 let pvk = pak.verification_key();
256
257 let auth_policy = vec![
258 AuthPolicy::DestinationAllowList {
259 allowed_destination_addresses: vec![
260 spend_key
261 .incoming_viewing_key()
262 .payment_address(Default::default())
263 .0,
264 ],
265 },
266 AuthPolicy::OnlyIbcRelay,
267 AuthPolicy::PreAuthorization(PreAuthorizationPolicy::Ed25519 {
268 required_signatures: 1,
269 allowed_signers: vec![pvk],
270 }),
271 ];
272 soft_kms::Config {
273 spend_key,
274 auth_policy,
275 }
276 });
277
278 let client_config = PclientdConfig {
279 kms_config,
280 full_viewing_key,
281 grpc_url: grpc_url.clone(),
282 bind_addr: *bind_addr,
283 };
284
285 let encoded = toml::to_string_pretty(&client_config)
286 .expect("able to convert client config to toml string");
287
288 let config_file_path = &mut opt.home.clone();
291 config_file_path.push("config.toml");
292 let mut config_file = File::create(&config_file_path)?;
293
294 config_file.write_all(encoded.as_bytes())?;
295
296 Ok(())
297 }
298 Command::Start {} => {
299 let config = PclientdConfig::load(opt.config_path()).context(
300 "Failed to load pclientd config file. Have you run `pclientd init` with a FVK?",
301 )?;
302
303 tracing::info!(?opt.home, ?config.bind_addr, %config.grpc_url, "starting pclientd");
304 let storage = opt
305 .load_or_init_sqlite(&config.full_viewing_key, &config.grpc_url)
306 .await?;
307
308 let proxy_channel = ViewServer::get_pd_channel(config.grpc_url.clone()).await?;
309
310 let app_query_proxy = AppQueryProxy(proxy_channel.clone());
311 let governance_query_proxy = GovernanceQueryProxy(proxy_channel.clone());
312 let dex_query_proxy = DexQueryProxy(proxy_channel.clone());
313 let dex_simulation_proxy = DexSimulationProxy(proxy_channel.clone());
314 let sct_query_proxy = SctQueryProxy(proxy_channel.clone());
315 let fee_query_proxy = FeeQueryProxy(proxy_channel.clone());
316 let shielded_pool_query_proxy = ShieldedPoolQueryProxy(proxy_channel.clone());
317 let chain_query_proxy = ChainQueryProxy(proxy_channel.clone());
318 let stake_query_proxy = StakeQueryProxy(proxy_channel.clone());
319 let compact_block_query_proxy = CompactBlockQueryProxy(proxy_channel.clone());
320 let tendermint_proxy_proxy = TendermintProxyProxy(proxy_channel.clone());
321
322 let view_service =
323 ViewServiceServer::new(ViewServer::new(storage, config.grpc_url).await?);
324 let custody_service = config.kms_config.as_ref().map(|kms_config| {
325 CustodyServiceServer::new(SoftKms::new(kms_config.spend_key.clone().into()))
326 });
327
328 let server = Server::builder()
329 .accept_http1(true)
330 .add_service(tonic_web::enable(view_service))
331 .add_optional_service(custody_service.map(tonic_web::enable))
332 .add_service(tonic_web::enable(app_query_proxy))
333 .add_service(tonic_web::enable(governance_query_proxy))
334 .add_service(tonic_web::enable(dex_query_proxy))
335 .add_service(tonic_web::enable(dex_simulation_proxy))
336 .add_service(tonic_web::enable(sct_query_proxy))
337 .add_service(tonic_web::enable(fee_query_proxy))
338 .add_service(tonic_web::enable(shielded_pool_query_proxy))
339 .add_service(tonic_web::enable(chain_query_proxy))
340 .add_service(tonic_web::enable(stake_query_proxy))
341 .add_service(tonic_web::enable(compact_block_query_proxy))
342 .add_service(tonic_web::enable(tendermint_proxy_proxy))
343 .add_service(tonic_web::enable(
346 tonic_reflection::server::Builder::configure()
347 .register_encoded_file_descriptor_set(
348 penumbra_sdk_proto::FILE_DESCRIPTOR_SET,
349 )
350 .build_v1()
351 .with_context(|| "could not configure grpc reflection service")?,
352 ))
353 .serve(config.bind_addr);
354
355 tokio::spawn(server).await??;
356
357 Ok(())
358 }
359 }
360 }
361}