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 reqwest;
26use rpassword::prompt_password;
27use serde::{Deserialize, Serialize};
28use serde_with::{serde_as, DisplayFromStr};
29use tempfile::NamedTempFile;
30
31use std::fs;
32use std::fs::File;
33use std::io::Write;
34use std::str::FromStr;
35use tonic::transport::Server;
36use url::Url;
37
38mod proxy;
39pub use proxy::{
40 AppQueryProxy, ChainQueryProxy, CompactBlockQueryProxy, DexQueryProxy, DexSimulationProxy,
41 GovernanceQueryProxy, SctQueryProxy, ShieldedPoolQueryProxy, StakeQueryProxy,
42 TendermintProxyProxy,
43};
44
45use crate::proxy::FeeQueryProxy;
46
47#[serde_as]
48#[derive(Serialize, Deserialize, Clone, Debug)]
49pub struct PclientdConfig {
50 #[serde_as(as = "DisplayFromStr")]
52 pub full_viewing_key: FullViewingKey,
53 pub grpc_url: Url,
55 pub bind_addr: SocketAddr,
57 pub kms_config: Option<soft_kms::Config>,
59}
60
61impl PclientdConfig {
62 pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
63 let contents = std::fs::read_to_string(path)?;
64 Ok(toml::from_str(&contents)?)
65 }
66
67 pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
68 let contents = toml::to_string_pretty(&self)?;
69 std::fs::write(path, contents)?;
70 Ok(())
71 }
72}
73
74pub fn default_home() -> Utf8PathBuf {
75 let path = ProjectDirs::from("zone", "penumbra", "pclientd")
76 .expect("Failed to get platform data dir")
77 .data_dir()
78 .to_path_buf();
79 Utf8PathBuf::from_path_buf(path).expect("Platform default data dir was not UTF-8")
80}
81
82#[derive(Debug, Parser)]
83#[clap(name = "pclientd", about = "The Penumbra view daemon.", version)]
84pub struct Opt {
85 #[clap(subcommand)]
87 pub cmd: Command,
88 #[clap(long, default_value_t = default_home(), env = "PENUMBRA_PCLIENTD_HOME")]
90 pub home: Utf8PathBuf,
91}
92
93#[derive(Debug, clap::Subcommand)]
94pub enum Command {
95 Init {
103 #[clap(long, display_order = 100)]
107 view: bool,
108 #[clap(
110 long,
111 display_order = 900,
112 parse(try_from_str = Url::parse)
113 )]
114 grpc_url: Url,
115 #[clap(long, display_order = 900, default_value = "127.0.0.1:8081")]
117 bind_addr: SocketAddr,
118 },
119 Start {},
121 Reset {},
123 LoadRegistry {
127 #[clap(long)]
140 source: Option<String>,
141 },
142}
143
144impl Opt {
145 fn config_path(&self) -> Utf8PathBuf {
146 let mut path = self.home.clone();
147 path.push("config.toml");
148 path
149 }
150
151 fn sqlite_path(&self) -> Utf8PathBuf {
152 let mut path = self.home.clone();
153 path.push("pclientd-db.sqlite");
154 path
155 }
156
157 fn check_home_nonempty(&self) -> Result<()> {
158 if self.home.exists() {
159 if !self.home.is_dir() {
160 return Err(anyhow::anyhow!(
161 "The home directory {:?} is not a directory.",
162 self.home
163 ));
164 }
165 let mut entries = fs::read_dir(&self.home)?.peekable();
166 if entries.peek().is_some() {
167 return Err(anyhow::anyhow!(
168 "The home directory {:?} is not empty, refusing to overwrite it",
169 self.home
170 ));
171 }
172 } else {
173 fs::create_dir_all(&self.home)?;
174 }
175 Ok(())
176 }
177
178 async fn init_sqlite(&self, fvk: &FullViewingKey, grpc_url: &Url) -> Result<Storage> {
179 let mut client = AppQueryServiceClient::connect(grpc_url.to_string()).await?;
181
182 let params = client
183 .app_parameters(tonic::Request::new(AppParametersRequest {}))
184 .await?
185 .into_inner()
186 .try_into()?;
187
188 Storage::initialize(Some(self.sqlite_path()), fvk.clone(), params).await
189 }
190
191 async fn load_or_init_sqlite(&self, fvk: &FullViewingKey, grpc_url: &Url) -> Result<Storage> {
192 if self.sqlite_path().exists() {
193 Ok(Storage::load(self.sqlite_path()).await?)
194 } else {
195 self.init_sqlite(fvk, grpc_url).await
196 }
197 }
198
199 fn prompt_for_password(&self, msg: &str) -> Result<String> {
201 let mut password = String::new();
202 if std::io::stdin().is_terminal() {
206 password = prompt_password(msg)?;
207 } else {
208 while let Ok(n_bytes) = std::io::stdin().lock().read_to_string(&mut password) {
209 if n_bytes == 0 {
210 break;
211 }
212 password = password.trim().to_string();
213 }
214 }
215 Ok(password)
216 }
217
218 pub async fn exec(self) -> Result<()> {
219 let opt = self;
220 match &opt.cmd {
221 Command::Reset {} => {
222 if opt.sqlite_path().exists() {
223 fs::remove_file(opt.sqlite_path())?;
224 println!("Deleted local storage at: {:?}", opt.sqlite_path());
225 } else {
226 println!("No local storage at: {:?} (have you started pclientd, so it would have data to store?)", opt.sqlite_path());
227 }
228
229 Ok(())
230 }
231 Command::Init {
232 view,
233 grpc_url,
234 bind_addr,
235 } => {
236 opt.check_home_nonempty()?;
238
239 let key_material: String;
241 let spend_key: Option<SpendKey>;
242 let full_viewing_key: FullViewingKey;
243
244 if *view {
246 key_material = opt
247 .prompt_for_password("Enter full viewing key: ")?
248 .to_owned();
249 full_viewing_key = key_material.parse()?;
250 spend_key = None;
251 } else {
253 key_material = opt
254 .prompt_for_password(
255 "Enter your seed phrase to enable pclientd custody mode: ",
256 )?
257 .to_owned();
258 let sk = SpendKey::from_seed_phrase_bip44(
259 SeedPhrase::from_str(key_material.as_str())?,
260 &Bip44Path::new(0),
261 );
262 full_viewing_key = sk.full_viewing_key().clone();
263 spend_key = Some(sk);
264 }
265
266 println!(
267 "Initializing configuration at: {:?}",
268 fs::canonicalize(&opt.home)?
269 );
270
271 let kms_config: Option<soft_kms::Config> = spend_key.map(|spend_key| {
273 let pak = ed25519_consensus::SigningKey::new(rand_core::OsRng);
276 let pvk = pak.verification_key();
277
278 let auth_policy = vec![
279 AuthPolicy::DestinationAllowList {
280 allowed_destination_addresses: vec![
281 spend_key
282 .incoming_viewing_key()
283 .payment_address(Default::default())
284 .0,
285 ],
286 },
287 AuthPolicy::OnlyIbcRelay,
288 AuthPolicy::PreAuthorization(PreAuthorizationPolicy::Ed25519 {
289 required_signatures: 1,
290 allowed_signers: vec![pvk],
291 }),
292 ];
293 soft_kms::Config {
294 spend_key,
295 auth_policy,
296 }
297 });
298
299 let client_config = PclientdConfig {
300 kms_config,
301 full_viewing_key,
302 grpc_url: grpc_url.clone(),
303 bind_addr: *bind_addr,
304 };
305
306 let encoded = toml::to_string_pretty(&client_config)
307 .expect("able to convert client config to toml string");
308
309 let config_file_path = &mut opt.home.clone();
312 config_file_path.push("config.toml");
313 let mut config_file = File::create(&config_file_path)?;
314
315 config_file.write_all(encoded.as_bytes())?;
316
317 Ok(())
318 }
319 Command::Start {} => {
320 let config = PclientdConfig::load(opt.config_path()).context(
321 "Failed to load pclientd config file. Have you run `pclientd init` with a FVK?",
322 )?;
323
324 tracing::info!(?opt.home, ?config.bind_addr, %config.grpc_url, "starting pclientd");
325 let storage = opt
326 .load_or_init_sqlite(&config.full_viewing_key, &config.grpc_url)
327 .await?;
328
329 let proxy_channel = ViewServer::get_pd_channel(config.grpc_url.clone()).await?;
330
331 let app_query_proxy = AppQueryProxy(proxy_channel.clone());
332 let governance_query_proxy = GovernanceQueryProxy(proxy_channel.clone());
333 let dex_query_proxy = DexQueryProxy(proxy_channel.clone());
334 let dex_simulation_proxy = DexSimulationProxy(proxy_channel.clone());
335 let sct_query_proxy = SctQueryProxy(proxy_channel.clone());
336 let fee_query_proxy = FeeQueryProxy(proxy_channel.clone());
337 let shielded_pool_query_proxy = ShieldedPoolQueryProxy(proxy_channel.clone());
338 let chain_query_proxy = ChainQueryProxy(proxy_channel.clone());
339 let stake_query_proxy = StakeQueryProxy(proxy_channel.clone());
340 let compact_block_query_proxy = CompactBlockQueryProxy(proxy_channel.clone());
341 let tendermint_proxy_proxy = TendermintProxyProxy(proxy_channel.clone());
342
343 let view_service =
344 ViewServiceServer::new(ViewServer::new(storage, config.grpc_url).await?);
345 let custody_service = config.kms_config.as_ref().map(|kms_config| {
346 CustodyServiceServer::new(SoftKms::new(kms_config.spend_key.clone().into()))
347 });
348
349 let server = Server::builder()
350 .accept_http1(true)
351 .add_service(tonic_web::enable(view_service))
352 .add_optional_service(custody_service.map(tonic_web::enable))
353 .add_service(tonic_web::enable(app_query_proxy))
354 .add_service(tonic_web::enable(governance_query_proxy))
355 .add_service(tonic_web::enable(dex_query_proxy))
356 .add_service(tonic_web::enable(dex_simulation_proxy))
357 .add_service(tonic_web::enable(sct_query_proxy))
358 .add_service(tonic_web::enable(fee_query_proxy))
359 .add_service(tonic_web::enable(shielded_pool_query_proxy))
360 .add_service(tonic_web::enable(chain_query_proxy))
361 .add_service(tonic_web::enable(stake_query_proxy))
362 .add_service(tonic_web::enable(compact_block_query_proxy))
363 .add_service(tonic_web::enable(tendermint_proxy_proxy))
364 .add_service(tonic_web::enable(
367 tonic_reflection::server::Builder::configure()
368 .register_encoded_file_descriptor_set(
369 penumbra_sdk_proto::FILE_DESCRIPTOR_SET,
370 )
371 .build_v1()
372 .with_context(|| "could not configure grpc reflection service")?,
373 ))
374 .serve(config.bind_addr);
375
376 tokio::spawn(server).await??;
377
378 Ok(())
379 }
380 Command::LoadRegistry { source } => {
381 let config = PclientdConfig::load(opt.config_path()).context(
382 "Failed to load pclientd config file. Have you run `pclientd init` with a FVK?",
383 )?;
384
385 let storage = opt
387 .load_or_init_sqlite(&config.full_viewing_key, &config.grpc_url)
388 .await?;
389
390 let source_url = source.clone().unwrap_or_else(|| {
392 "https://raw.githubusercontent.com/prax-wallet/registry/refs/heads/main/registry/chains/".to_string()
393 });
394
395 let registry_url = determine_registry_url(&source_url, &storage).await?;
397
398 tracing::info!(?registry_url, "Loading assets from registry");
399
400 let temp_file = download_registry_to_temp_file(®istry_url).await?;
402
403 let temp_path = camino::Utf8Path::from_path(temp_file.path())
405 .ok_or_else(|| anyhow::anyhow!("Temporary file path is not valid UTF-8"))?;
406 storage.load_asset_metadata(temp_path).await?;
407
408 println!("Successfully loaded assets from registry: {}", registry_url);
409 Ok(())
410 }
411 }
412 }
413}
414
415async fn determine_registry_url(source: &str, storage: &Storage) -> Result<String> {
417 if source.ends_with(".json") {
418 Ok(source.to_string())
420 } else {
421 let app_params = storage.app_params().await?;
423 let chain_id = app_params.chain_id;
424 let mut url = source.to_string();
425 if !url.ends_with('/') {
426 url.push('/');
427 }
428 url.push_str(&format!("{}.json", chain_id));
429 Ok(url)
430 }
431}
432
433async fn download_registry_to_temp_file(url: &str) -> Result<NamedTempFile> {
435 if url.starts_with("file://") {
436 let local_path = &url[7..]; let content = std::fs::read_to_string(local_path)
439 .with_context(|| format!("Failed to read local registry file: {}", local_path))?;
440
441 let mut temp_file = NamedTempFile::new().context("Failed to create temporary file")?;
442 temp_file
443 .write_all(content.as_bytes())
444 .context("Failed to write to temporary file")?;
445 temp_file
446 .flush()
447 .context("Failed to flush temporary file")?;
448
449 Ok(temp_file)
450 } else {
451 let response = reqwest::get(url)
453 .await
454 .with_context(|| format!("Failed to download registry from: {}", url))?;
455
456 if !response.status().is_success() {
457 return Err(anyhow::anyhow!(
458 "Failed to download registry: HTTP {}",
459 response.status()
460 ));
461 }
462
463 let content = response
464 .text()
465 .await
466 .context("Failed to read response body")?;
467
468 let mut temp_file = NamedTempFile::new().context("Failed to create temporary file")?;
469 temp_file
470 .write_all(content.as_bytes())
471 .context("Failed to write to temporary file")?;
472 temp_file
473 .flush()
474 .context("Failed to flush temporary file")?;
475
476 Ok(temp_file)
477 }
478}