pclientd/
lib.rs

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