pcli/
terminal.rs

1use std::io::{IsTerminal, Read, Write};
2
3use anyhow::Result;
4use decaf377::{Element, Fq};
5use decaf377_rdsa::{Domain, Signature, VerificationKey};
6use penumbra_sdk_asset::{asset::Cache, balance::Commitment};
7use penumbra_sdk_custody::threshold::{SigningRequest, Terminal};
8use penumbra_sdk_keys::{
9    symmetric::{OvkWrappedKey, WrappedMemoKey},
10    FullViewingKey, PayloadKey,
11};
12use penumbra_sdk_proof_params::GROTH16_PROOF_LENGTH_BYTES;
13use penumbra_sdk_sct::Nullifier;
14use penumbra_sdk_shielded_pool::{EncryptedBackref, Note, NoteView};
15use penumbra_sdk_tct::structure::Hash;
16use penumbra_sdk_transaction::{view, ActionPlan, ActionView, TransactionPlan, TransactionView};
17use termion::{color, input::TermRead};
18use tonic::async_trait;
19
20use crate::transaction_view_ext::TransactionViewExt as _;
21
22async fn read_password(prompt: &str) -> Result<String> {
23    fn get_possibly_empty_string(prompt: &str) -> Result<String> {
24        // The `rpassword` crate doesn't support reading from stdin, so we check
25        // for an interactive session. We must support non-interactive use cases,
26        // for integration with other tooling.
27        if std::io::stdin().is_terminal() {
28            Ok(rpassword::prompt_password(prompt)?)
29        } else {
30            Ok(std::io::stdin().lock().read_line()?.unwrap_or_default())
31        }
32    }
33
34    let mut string: String = Default::default();
35    while string.is_empty() {
36        // Keep trying until the user provides an input
37        string = get_possibly_empty_string(prompt)?;
38    }
39    Ok(string)
40}
41
42fn pretty_print_transaction_plan(
43    fvk: Option<FullViewingKey>,
44    plan: &TransactionPlan,
45) -> anyhow::Result<()> {
46    use penumbra_sdk_shielded_pool::{output, spend};
47
48    fn dummy_sig<D: Domain>() -> Signature<D> {
49        Signature::from([0u8; 64])
50    }
51
52    fn dummy_pk<D: Domain>() -> VerificationKey<D> {
53        VerificationKey::try_from(Element::default().vartime_compress().0)
54            .expect("creating a dummy verification key should work")
55    }
56
57    fn dummy_commitment() -> Commitment {
58        Commitment(Element::default())
59    }
60
61    fn dummy_proof_spend() -> spend::SpendProof {
62        spend::SpendProof::try_from(
63            penumbra_sdk_proto::penumbra::core::component::shielded_pool::v1::ZkSpendProof {
64                inner: vec![0u8; GROTH16_PROOF_LENGTH_BYTES],
65            },
66        )
67        .expect("creating a dummy proof should work")
68    }
69
70    fn dummy_proof_output() -> output::OutputProof {
71        output::OutputProof::try_from(
72            penumbra_sdk_proto::penumbra::core::component::shielded_pool::v1::ZkOutputProof {
73                inner: vec![0u8; GROTH16_PROOF_LENGTH_BYTES],
74            },
75        )
76        .expect("creating a dummy proof should work")
77    }
78
79    fn dummy_spend() -> spend::Spend {
80        spend::Spend {
81            body: spend::Body {
82                balance_commitment: dummy_commitment(),
83                nullifier: Nullifier(Fq::default()),
84                rk: dummy_pk(),
85                encrypted_backref: EncryptedBackref::try_from([0u8; 0])
86                    .expect("can create dummy encrypted backref"),
87            },
88            auth_sig: dummy_sig(),
89            proof: dummy_proof_spend(),
90        }
91    }
92
93    fn dummy_output() -> output::Output {
94        output::Output {
95            body: output::Body {
96                note_payload: penumbra_sdk_shielded_pool::NotePayload {
97                    note_commitment: penumbra_sdk_shielded_pool::note::StateCommitment(
98                        Fq::default(),
99                    ),
100                    ephemeral_key: [0u8; 32]
101                        .as_slice()
102                        .try_into()
103                        .expect("can create dummy ephemeral key"),
104                    encrypted_note: penumbra_sdk_shielded_pool::NoteCiphertext([0u8; 176]),
105                },
106                balance_commitment: dummy_commitment(),
107                ovk_wrapped_key: OvkWrappedKey([0u8; 48]),
108                wrapped_memo_key: WrappedMemoKey([0u8; 48]),
109            },
110            proof: dummy_proof_output(),
111        }
112    }
113
114    fn convert_note(cache: &Cache, fvk: &FullViewingKey, note: &Note) -> NoteView {
115        NoteView {
116            value: note.value().view_with_cache(cache),
117            rseed: note.rseed(),
118            address: fvk.view_address(note.address()),
119        }
120    }
121
122    fn convert_action(
123        cache: &Cache,
124        fvk: &FullViewingKey,
125        action: &ActionPlan,
126    ) -> Option<ActionView> {
127        use view::action_view::SpendView;
128
129        match action {
130            ActionPlan::Output(x) => Some(ActionView::Output(
131                penumbra_sdk_shielded_pool::OutputView::Visible {
132                    output: dummy_output(),
133                    note: convert_note(cache, fvk, &x.output_note()),
134                    payload_key: PayloadKey::from([0u8; 32]),
135                },
136            )),
137            ActionPlan::Spend(x) => Some(ActionView::Spend(SpendView::Visible {
138                spend: dummy_spend(),
139                note: convert_note(cache, fvk, &x.note),
140            })),
141            ActionPlan::ValidatorDefinition(_) => None,
142            ActionPlan::Swap(_) => None,
143            ActionPlan::SwapClaim(_) => None,
144            ActionPlan::ProposalSubmit(_) => None,
145            ActionPlan::ProposalWithdraw(_) => None,
146            ActionPlan::DelegatorVote(_) => None,
147            ActionPlan::ValidatorVote(_) => None,
148            ActionPlan::ProposalDepositClaim(_) => None,
149            ActionPlan::PositionOpen(_) => None,
150            ActionPlan::PositionClose(_) => None,
151            ActionPlan::PositionWithdraw(_) => None,
152            ActionPlan::Delegate(_) => None,
153            ActionPlan::Undelegate(_) => None,
154            ActionPlan::UndelegateClaim(_) => None,
155            ActionPlan::Ics20Withdrawal(_) => None,
156            ActionPlan::CommunityPoolSpend(_) => None,
157            ActionPlan::CommunityPoolOutput(_) => None,
158            ActionPlan::CommunityPoolDeposit(_) => None,
159            ActionPlan::ActionDutchAuctionSchedule(_) => None,
160            ActionPlan::ActionDutchAuctionEnd(_) => None,
161            ActionPlan::ActionDutchAuctionWithdraw(_) => None,
162            ActionPlan::IbcAction(_) => todo!(),
163            ActionPlan::ActionLiquidityTournamentVote(_) => None,
164        }
165    }
166
167    // Regardless of if we have the FVK, we can print the raw plan
168    println!("{}", serde_json::to_string_pretty(plan)?);
169
170    // The rest of the printing requires the FVK
171    let fvk = match fvk {
172        None => {
173            return Ok(());
174        }
175        Some(x) => x,
176    };
177
178    let cache = Cache::with_known_assets();
179
180    let view = TransactionView {
181        anchor: penumbra_sdk_tct::Root(Hash::zero()),
182        binding_sig: dummy_sig(),
183        body_view: view::TransactionBodyView {
184            action_views: plan
185                .actions
186                .iter()
187                .filter_map(|x| convert_action(&cache, &fvk, x))
188                .collect(),
189            transaction_parameters: plan.transaction_parameters.clone(),
190            detection_data: None,
191            memo_view: None,
192        },
193    };
194
195    view.render_terminal();
196
197    Ok(())
198}
199
200/// For threshold custody, we need to implement this weird terminal abstraction.
201///
202/// This actually does stuff to stdin and stdout.
203#[derive(Clone, Default)]
204pub struct ActualTerminal {
205    pub fvk: Option<FullViewingKey>,
206}
207
208#[async_trait]
209impl Terminal for ActualTerminal {
210    async fn confirm_request(&self, signing_request: &SigningRequest) -> Result<bool> {
211        match signing_request {
212            SigningRequest::TransactionPlan(plan) => {
213                pretty_print_transaction_plan(self.fvk.clone(), plan)?;
214                println!("Do you approve this transaction?");
215            }
216            SigningRequest::ValidatorDefinition(def) => {
217                println!("{}", serde_json::to_string_pretty(def)?);
218                println!("Do you approve this validator definition?");
219            }
220            SigningRequest::ValidatorVote(vote) => {
221                println!("{}", serde_json::to_string_pretty(vote)?);
222                println!("Do you approve this validator vote?");
223            }
224        };
225
226        println!("Press enter to continue");
227        self.read_line_raw().await?;
228        Ok(true)
229    }
230
231    fn explain(&self, msg: &str) -> Result<()> {
232        println!(
233            "{}{}{}",
234            color::Fg(color::Blue),
235            msg,
236            color::Fg(color::Reset)
237        );
238        Ok(())
239    }
240
241    async fn broadcast(&self, data: &str) -> Result<()> {
242        println!(
243            "\n{}{}{}\n",
244            color::Fg(color::Yellow),
245            data,
246            color::Fg(color::Reset)
247        );
248        Ok(())
249    }
250
251    async fn read_line_raw(&self) -> Result<String> {
252        // Use raw mode to allow reading more than 1KB/4KB of data at a time
253        // See https://unix.stackexchange.com/questions/204815/terminal-does-not-accept-pasted-or-typed-lines-of-more-than-1024-characters
254        use termion::raw::IntoRawMode;
255        tracing::debug!("about to enter raw mode for long pasted input");
256
257        print!("{}", color::Fg(color::Red));
258        // In raw mode, the input is not mirrored into the terminal, so we need
259        // to read char-by-char and echo it back.
260        let mut stdout = std::io::stdout().into_raw_mode()?;
261
262        let mut bytes = Vec::with_capacity(8192);
263        for b in std::io::stdin().bytes() {
264            let b = b?;
265            // In raw mode, we need to handle control characters ourselves
266            if b == 3 || b == 4 {
267                // Ctrl-C or Ctrl-D
268                return Err(anyhow::anyhow!("aborted"));
269            }
270            // In raw mode, the enter key might generate \r or \n, check either.
271            if b == b'\n' || b == b'\r' {
272                break;
273            }
274            // Store the byte we read and print it back to the terminal.
275            bytes.push(b);
276            stdout.write_all(&[b]).expect("stdout write failed");
277            // Flushing may not be the most efficient but performance isn't critical here.
278            stdout.flush()?;
279        }
280        // Drop _stdout to restore the terminal to normal mode
281        std::mem::drop(stdout);
282        // We consumed a newline of some kind but didn't echo it, now print
283        // one out so subsequent output is guaranteed to be on a new line.
284        println!("");
285        print!("{}", color::Fg(color::Reset));
286
287        tracing::debug!("exited raw mode and returned to cooked mode");
288
289        let line = String::from_utf8(bytes)?;
290        tracing::debug!(?line, "read response line");
291
292        Ok(line)
293    }
294
295    async fn get_password(&self) -> Result<String> {
296        read_password("Enter Password: ").await
297    }
298}
299
300impl ActualTerminal {
301    pub async fn get_confirmed_password() -> Result<String> {
302        loop {
303            let password = read_password("Enter Password: ").await?;
304            let confirmed = read_password("Confirm Password: ").await?;
305            if password != confirmed {
306                println!("Password mismatch, please try again.");
307                continue;
308            }
309            return Ok(password);
310        }
311    }
312}