1#![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 #[serde_as(as = "DisplayFromStr")]
47 pub full_viewing_key: FullViewingKey,
48 pub grpc_url: Url,
50 pub bind_addr: SocketAddr,
52 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 #[clap(subcommand)]
82 pub cmd: Command,
83 #[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 Init {
92 #[clap(long, display_order = 100, value_name = "FULL_VIEWING_KEY")]
94 view: Option<String>,
95 #[clap(long, display_order = 200)]
99 custody: Option<String>,
100 #[clap(
102 long,
103 display_order = 900,
104 parse(try_from_str = Url::parse)
105 )]
106 grpc_url: Url,
107 #[clap(long, display_order = 900, default_value = "127.0.0.1:8081")]
109 bind_addr: SocketAddr,
110 },
111 Start {},
113 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 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 opt.check_home_nonempty()?;
193
194 let seed_phrase = match custody {
195 None => None,
196 Some(seed_phrase) => {
197 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 let kms_config: Option<soft_kms::Config> = spend_key.map(|spend_key| {
245 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 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 .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}