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        }
164    }
165
166    // Regardless of if we have the FVK, we can print the raw plan
167    println!("{}", serde_json::to_string_pretty(plan)?);
168
169    // The rest of the printing requires the FVK
170    let fvk = match fvk {
171        None => {
172            return Ok(());
173        }
174        Some(x) => x,
175    };
176
177    let cache = Cache::with_known_assets();
178
179    let view = TransactionView {
180        anchor: penumbra_sdk_tct::Root(Hash::zero()),
181        binding_sig: dummy_sig(),
182        body_view: view::TransactionBodyView {
183            action_views: plan
184                .actions
185                .iter()
186                .filter_map(|x| convert_action(&cache, &fvk, x))
187                .collect(),
188            transaction_parameters: plan.transaction_parameters.clone(),
189            detection_data: None,
190            memo_view: None,
191        },
192    };
193
194    view.render_terminal();
195
196    Ok(())
197}
198
199/// For threshold custody, we need to implement this weird terminal abstraction.
200///
201/// This actually does stuff to stdin and stdout.
202#[derive(Clone, Default)]
203pub struct ActualTerminal {
204    pub fvk: Option<FullViewingKey>,
205}
206
207#[async_trait]
208impl Terminal for ActualTerminal {
209    async fn confirm_request(&self, signing_request: &SigningRequest) -> Result<bool> {
210        match signing_request {
211            SigningRequest::TransactionPlan(plan) => {
212                pretty_print_transaction_plan(self.fvk.clone(), plan)?;
213                println!("Do you approve this transaction?");
214            }
215            SigningRequest::ValidatorDefinition(def) => {
216                println!("{}", serde_json::to_string_pretty(def)?);
217                println!("Do you approve this validator definition?");
218            }
219            SigningRequest::ValidatorVote(vote) => {
220                println!("{}", serde_json::to_string_pretty(vote)?);
221                println!("Do you approve this validator vote?");
222            }
223        };
224
225        println!("Press enter to continue");
226        self.read_line_raw().await?;
227        Ok(true)
228    }
229
230    fn explain(&self, msg: &str) -> Result<()> {
231        println!(
232            "{}{}{}",
233            color::Fg(color::Blue),
234            msg,
235            color::Fg(color::Reset)
236        );
237        Ok(())
238    }
239
240    async fn broadcast(&self, data: &str) -> Result<()> {
241        println!(
242            "\n{}{}{}\n",
243            color::Fg(color::Yellow),
244            data,
245            color::Fg(color::Reset)
246        );
247        Ok(())
248    }
249
250    async fn read_line_raw(&self) -> Result<String> {
251        // Use raw mode to allow reading more than 1KB/4KB of data at a time
252        // See https://unix.stackexchange.com/questions/204815/terminal-does-not-accept-pasted-or-typed-lines-of-more-than-1024-characters
253        use termion::raw::IntoRawMode;
254        tracing::debug!("about to enter raw mode for long pasted input");
255
256        print!("{}", color::Fg(color::Red));
257        // In raw mode, the input is not mirrored into the terminal, so we need
258        // to read char-by-char and echo it back.
259        let mut stdout = std::io::stdout().into_raw_mode()?;
260
261        let mut bytes = Vec::with_capacity(8192);
262        for b in std::io::stdin().bytes() {
263            let b = b?;
264            // In raw mode, we need to handle control characters ourselves
265            if b == 3 || b == 4 {
266                // Ctrl-C or Ctrl-D
267                return Err(anyhow::anyhow!("aborted"));
268            }
269            // In raw mode, the enter key might generate \r or \n, check either.
270            if b == b'\n' || b == b'\r' {
271                break;
272            }
273            // Store the byte we read and print it back to the terminal.
274            bytes.push(b);
275            stdout.write_all(&[b]).expect("stdout write failed");
276            // Flushing may not be the most efficient but performance isn't critical here.
277            stdout.flush()?;
278        }
279        // Drop _stdout to restore the terminal to normal mode
280        std::mem::drop(stdout);
281        // We consumed a newline of some kind but didn't echo it, now print
282        // one out so subsequent output is guaranteed to be on a new line.
283        println!("");
284        print!("{}", color::Fg(color::Reset));
285
286        tracing::debug!("exited raw mode and returned to cooked mode");
287
288        let line = String::from_utf8(bytes)?;
289        tracing::debug!(?line, "read response line");
290
291        Ok(line)
292    }
293
294    async fn get_password(&self) -> Result<String> {
295        read_password("Enter Password: ").await
296    }
297}
298
299impl ActualTerminal {
300    pub async fn get_confirmed_password() -> Result<String> {
301        loop {
302            let password = read_password("Enter Password: ").await?;
303            let confirmed = read_password("Confirm Password: ").await?;
304            if password != confirmed {
305                println!("Password mismatch, please try again.");
306                continue;
307            }
308            return Ok(password);
309        }
310    }
311}