pclientd/
lib.rs

1// Requires nightly.
2#![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    /// FVK for both view and custody modes
51    #[serde_as(as = "DisplayFromStr")]
52    pub full_viewing_key: FullViewingKey,
53    /// The URL of the gRPC endpoint used to talk to pd.
54    pub grpc_url: Url,
55    /// The address to bind to serve gRPC.
56    pub bind_addr: SocketAddr,
57    /// Optional KMS config for custody mode
58    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    /// Command to run.
86    #[clap(subcommand)]
87    pub cmd: Command,
88    /// The path used to store pclientd state and config files.
89    #[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    /// Generate configs for `pclientd` in view or custody mode.
96    ///
97    /// In custody mode, pclientd will have spend authority over the configured account,
98    /// enabling it to perform transactions on behalf of the wallet.
99    ///
100    /// In view mode, pclientd will be able to read all transactions related to the
101    /// configured account, but cannot create new transactions.
102    Init {
103        /// If provided, initialize in view mode, by providing a full viewing key.
104        ///
105        /// Otherwise, a prompt will accept a seed phrase.
106        #[clap(long, display_order = 100)]
107        view: bool,
108        /// Sets the URL of the gRPC endpoint used to talk to pd.
109        #[clap(
110            long,
111            display_order = 900,
112            parse(try_from_str = Url::parse)
113        )]
114        grpc_url: Url,
115        /// Sets the address to bind to serve gRPC.
116        #[clap(long, display_order = 900, default_value = "127.0.0.1:8081")]
117        bind_addr: SocketAddr,
118    },
119    /// Start running `pclientd`.
120    Start {},
121    /// Delete `pclientd` storage to reset local state.
122    Reset {},
123    /// Load assets from a registry into the pclientd instance.
124    ///
125    /// This enables smarter handling of metadata.
126    LoadRegistry {
127        /// If present, where to fetch the assets from.
128        ///
129        /// If this is not present, this will use the Prax wallet registry, with the chain
130        /// ID pclientd has previously been initialize with to source the correct registry.
131        ///
132        /// If this is present, it will be assumed to be an HTTP URL. If the URL ends in ".json",
133        /// it's assumed to be a specific registry file, which will be fetched. If the URL
134        /// does not end in ".json", it will be concatened with the chain id pclientd has,
135        /// in the assumption that this points to a folder of registry files.
136        ///
137        /// If the URL starts with "file://" instead of "https://" or "http://", then
138        /// the local filesystem will be used, with all the same rules.
139        #[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        // Initialize client and storage
180        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    // Reusable function for prompting for sensitive info on the CLI.
200    fn prompt_for_password(&self, msg: &str) -> Result<String> {
201        let mut password = String::new();
202        // The `rpassword` crate doesn't support reading from stdin, so we check
203        // for an interactive session. We must support non-interactive use cases,
204        // for integration with other tooling.
205        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                // Check that the home directory is empty.
237                opt.check_home_nonempty()?;
238
239                // Initialize key vars, which will differ based on view or custody mode.
240                let key_material: String;
241                let spend_key: Option<SpendKey>;
242                let full_viewing_key: FullViewingKey;
243
244                // If view-only mode is requested, prompt for a FullViewingKey.
245                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                // Otherwise, we're in full custody mode.
252                } 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                // Create config file with example authorization policy.
272                let kms_config: Option<soft_kms::Config> = spend_key.map(|spend_key| {
273                    // It's important that we throw away the signing key here, so that
274                    // by default the config is "cannot spend funds" without manual editing.
275                    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                // Write config to directory
310
311                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                    // TODO: should we add the IBC services here as well? they will appear
365                    // in reflection but not be available.
366                    .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                // Load existing storage
386                let storage = opt
387                    .load_or_init_sqlite(&config.full_viewing_key, &config.grpc_url)
388                    .await?;
389
390                // Use provided source or default to Prax wallet registry
391                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                // Determine the final registry URL
396                let registry_url = determine_registry_url(&source_url, &storage).await?;
397
398                tracing::info!(?registry_url, "Loading assets from registry");
399
400                // Download the registry file to a temporary file
401                let temp_file = download_registry_to_temp_file(&registry_url).await?;
402
403                // Load asset metadata into the storage
404                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
415/// Determines the final registry URL based on the provided source and storage chain ID.
416async fn determine_registry_url(source: &str, storage: &Storage) -> Result<String> {
417    if source.ends_with(".json") {
418        // Direct registry file URL
419        Ok(source.to_string())
420    } else {
421        // Directory URL - need to append chain ID
422        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
433/// Downloads a registry file from the given URL to a temporary file.
434async fn download_registry_to_temp_file(url: &str) -> Result<NamedTempFile> {
435    if url.starts_with("file://") {
436        // Local file - copy to temp file
437        let local_path = &url[7..]; // Remove "file://" prefix
438        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        // HTTP/HTTPS URL - download with reqwest
452        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}