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 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    /// FVK for both view and custody modes
49    #[serde_as(as = "DisplayFromStr")]
50    pub full_viewing_key: FullViewingKey,
51    /// The URL of the gRPC endpoint used to talk to pd.
52    pub grpc_url: Url,
53    /// The address to bind to serve gRPC.
54    pub bind_addr: SocketAddr,
55    /// Optional KMS config for custody mode
56    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    /// Command to run.
84    #[clap(subcommand)]
85    pub cmd: Command,
86    /// The path used to store pclientd state and config files.
87    #[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    /// Generate configs for `pclientd` in view or custody mode.
94    ///
95    /// In custody mode, pclientd will have spend authority over the configured account,
96    /// enabling it to perform transactions on behalf of the wallet.
97    ///
98    /// In view mode, pclientd will be able to read all transactions related to the
99    /// configured account, but cannot create new transactions.
100    Init {
101        /// If provided, initialize in view mode, by providing a full viewing key.
102        ///
103        /// Otherwise, a prompt will accept a seed phrase.
104        #[clap(long, display_order = 100)]
105        view: bool,
106        /// Sets the URL of the gRPC endpoint used to talk to pd.
107        #[clap(
108            long,
109            display_order = 900,
110            parse(try_from_str = Url::parse)
111        )]
112        grpc_url: Url,
113        /// Sets the address to bind to serve gRPC.
114        #[clap(long, display_order = 900, default_value = "127.0.0.1:8081")]
115        bind_addr: SocketAddr,
116    },
117    /// Start running `pclientd`.
118    Start {},
119    /// Delete `pclientd` storage to reset local state.
120    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        // Initialize client and storage
159        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    // Reusable function for prmopting for sensitive info on the CLI.
179    fn prompt_for_password(&self, msg: &str) -> Result<String> {
180        let mut password = String::new();
181        // The `rpassword` crate doesn't support reading from stdin, so we check
182        // for an interactive session. We must support non-interactive use cases,
183        // for integration with other tooling.
184        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                // Check that the home directory is empty.
216                opt.check_home_nonempty()?;
217
218                // Initialize key vars, which will differ based on view or custody mode.
219                let key_material: String;
220                let spend_key: Option<SpendKey>;
221                let full_viewing_key: FullViewingKey;
222
223                // If view-only mode is requested, prompt for a FullViewingKey.
224                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                // Otherwise, we're in full custody mode.
231                } 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                // Create config file with example authorization policy.
251                let kms_config: Option<soft_kms::Config> = spend_key.map(|spend_key| {
252                    // It's important that we throw away the signing key here, so that
253                    // by default the config is "cannot spend funds" without manual editing.
254                    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                // Write config to directory
289
290                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                    // TODO: should we add the IBC services here as well? they will appear
344                    // in reflection but not be available.
345                    .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}