pcli/
transaction_view_ext.rs

1use comfy_table::presets;
2use comfy_table::Table;
3use penumbra_sdk_asset::asset::Id;
4use penumbra_sdk_asset::asset::Metadata;
5use penumbra_sdk_asset::Value;
6use penumbra_sdk_asset::ValueView;
7use penumbra_sdk_dex::swap::SwapView;
8use penumbra_sdk_dex::swap_claim::SwapClaimView;
9use penumbra_sdk_fee::Fee;
10use penumbra_sdk_keys::AddressView;
11use penumbra_sdk_num::Amount;
12use penumbra_sdk_shielded_pool::SpendView;
13use penumbra_sdk_transaction::view::action_view::OutputView;
14use penumbra_sdk_transaction::TransactionView;
15
16// Issues identified:
17// TODO: FeeView
18// TODO: TradingPairView
19// Implemented some helper functions which may make more sense as methods on existing Structs
20
21// helper function to create a value view from a value and optional metadata
22fn create_value_view(value: Value, metadata: Option<Metadata>) -> ValueView {
23    match metadata {
24        Some(metadata) => ValueView::KnownAssetId {
25            amount: value.amount,
26            metadata,
27            equivalent_values: Vec::new(),
28            extended_metadata: None,
29        },
30        None => ValueView::UnknownAssetId {
31            amount: value.amount,
32            asset_id: value.asset_id,
33        },
34    }
35}
36
37// a helper function to create pretty placeholders for encrypted information
38fn format_opaque_bytes(bytes: &[u8]) -> String {
39    if bytes.len() < 8 {
40        return String::new();
41    } else {
42        /*
43        // TODO: Hm, this can allow the same color for both, should rejig things to avoid this
44        // Select foreground and background colors based on the first 8 bytes.
45        let fg_color_index = bytes[0] % 8;
46        let bg_color_index = bytes[4] % 8;
47
48        // ANSI escape codes for foreground and background colors.
49        let fg_color_code = 37; // 30 through 37 are foreground colors
50        let bg_color_code = 40; // 40 through 47 are background colors
51        */
52
53        // to be more general, perhaps this should be configurable
54        // an opaque address needs less space than an opaque memo, etc
55        let max_bytes = 32;
56        let rem = if bytes.len() > max_bytes {
57            bytes[0..max_bytes].to_vec()
58        } else {
59            bytes.to_vec()
60        };
61
62        // Convert the rest of the bytes to hexadecimal.
63        let hex_str = hex::encode_upper(rem);
64        let opaque_chars: String = hex_str
65            .chars()
66            .map(|c| {
67                match c {
68                    '0' => "\u{2595}",
69                    '1' => "\u{2581}",
70                    '2' => "\u{2582}",
71                    '3' => "\u{2583}",
72                    '4' => "\u{2584}",
73                    '5' => "\u{2585}",
74                    '6' => "\u{2586}",
75                    '7' => "\u{2587}",
76                    '8' => "\u{2588}",
77                    '9' => "\u{2589}",
78                    'A' => "\u{259A}",
79                    'B' => "\u{259B}",
80                    'C' => "\u{259C}",
81                    'D' => "\u{259D}",
82                    'E' => "\u{259E}",
83                    'F' => "\u{259F}",
84                    _ => "",
85                }
86                .to_string()
87            })
88            .collect();
89
90        //format!("\u{001b}[{};{}m{}", fg_color_code, bg_color_code, block_chars)
91        format!("{}", opaque_chars)
92    }
93}
94
95// feels like these functions should be extension traits of their respective structs
96// propose moving this to core/keys/src/address/view.rs
97fn format_address_view(address_view: &AddressView) -> String {
98    match address_view {
99        AddressView::Decoded {
100            address: _,
101            index,
102            wallet_id: _,
103        } => {
104            if !index.is_ephemeral() {
105                format!("[account {:?}]", index.account)
106            } else {
107                format!("[account {:?} (one-time address)]", index.account)
108            }
109        }
110        AddressView::Opaque { address } => {
111            // The address being opaque just means we can't see the internal structure,
112            // we should render the content so it can be copy-pasted.
113            format!("{}", address)
114        }
115    }
116}
117
118// feels like these functions should be extension traits of their respective structs
119// propose moving this to core/asset/src/value.rs
120fn format_value_view(value_view: &ValueView) -> String {
121    match value_view {
122        ValueView::KnownAssetId {
123            amount,
124            metadata: denom,
125            ..
126        } => {
127            let unit = denom.default_unit();
128            format!("{}{}", unit.format_value(*amount), unit)
129        }
130        ValueView::UnknownAssetId { amount, asset_id } => {
131            format!("{}{}", amount, asset_id)
132        }
133    }
134}
135
136fn format_amount_range(
137    start: Amount,
138    stop: Amount,
139    asset_id: &Id,
140    metadata: Option<&Metadata>,
141) -> String {
142    match metadata {
143        Some(denom) => {
144            let unit = denom.default_unit();
145            format!(
146                "({}..{}){}",
147                unit.format_value(start),
148                unit.format_value(stop),
149                unit
150            )
151        }
152        None => format!("({}..{}){}", start, stop, asset_id),
153    }
154}
155
156fn format_fee(fee: &Fee) -> String {
157    // TODO: Implement FeeView to show decrypted fee.
158    format!("{}", fee.amount())
159}
160
161fn format_asset_id(asset_id: &Id) -> String {
162    // TODO: Implement TradingPairView to show decrypted .asset_id()
163    let input = &asset_id.to_string();
164    let truncated = &input[0..10]; //passet1
165    let ellipsis = "...";
166    let end = &input[(input.len() - 3)..];
167    format!("{}{}{}", truncated, ellipsis, end)
168}
169
170// When handling ValueViews inside of a Visible variant of an ActionView, handling both cases might be needlessly verbose
171// potentially this makes sense as a method on the ValueView enum
172// propose moving this to core/asset/src/value.rs
173fn value_view_amount(value_view: &ValueView) -> Amount {
174    match value_view {
175        ValueView::KnownAssetId { amount, .. } | ValueView::UnknownAssetId { amount, .. } => {
176            *amount
177        }
178    }
179}
180
181pub trait TransactionViewExt {
182    /// Render this transaction view on stdout.
183    fn render_terminal(&self);
184}
185
186impl TransactionViewExt for TransactionView {
187    fn render_terminal(&self) {
188        let fee = &self.body_view.transaction_parameters.fee;
189        // the denomination should be visible here... does a FeeView exist?
190        println!("Fee: {}", format_fee(&fee));
191
192        println!(
193            "Expiration Height: {}",
194            &self.body_view.transaction_parameters.expiry_height
195        );
196
197        if let Some(memo_view) = &self.body_view.memo_view {
198            match memo_view {
199                penumbra_sdk_transaction::MemoView::Visible {
200                    plaintext,
201                    ciphertext: _,
202                } => {
203                    println!("Memo Sender: {}", &plaintext.return_address.address());
204                    println!("Memo Text: \n{}\n", &plaintext.text);
205                }
206                penumbra_sdk_transaction::MemoView::Opaque { ciphertext } => {
207                    println!("Encrypted Memo: \n{}\n", format_opaque_bytes(&ciphertext.0));
208                }
209            }
210        }
211
212        let mut actions_table = Table::new();
213        actions_table.load_preset(presets::NOTHING);
214        actions_table.set_header(vec!["Tx Action", "Description"]);
215
216        // Iterate over the ActionViews in the TxView & display as appropriate
217        for action_view in &self.body_view.action_views {
218            let action: String;
219
220            let row = match action_view {
221                penumbra_sdk_transaction::ActionView::Spend(spend) => {
222                    match spend {
223                        SpendView::Visible { spend: _, note } => {
224                            action = format!(
225                                "{} -> {}",
226                                format_address_view(&note.address),
227                                format_value_view(&note.value)
228                            );
229                            ["Spend", &action]
230                        }
231                        SpendView::Opaque { spend } => {
232                            let bytes = spend.body.nullifier.to_bytes(); // taken to be a unique value, for aesthetic reasons
233                            action = format_opaque_bytes(&bytes);
234                            ["Spend", &action]
235                        }
236                    }
237                }
238                penumbra_sdk_transaction::ActionView::Output(output) => {
239                    match output {
240                        OutputView::Visible {
241                            output: _,
242                            note,
243                            payload_key: _,
244                        } => {
245                            action = format!(
246                                "{} -> {}",
247                                format_value_view(&note.value),
248                                format_address_view(&note.address),
249                            );
250                            ["Output", &action]
251                        }
252                        OutputView::Opaque { output } => {
253                            let bytes = output.body.note_payload.encrypted_note.0; // taken to be a unique value, for aesthetic reasons
254                            action = format_opaque_bytes(&bytes);
255                            ["Output", &action]
256                        }
257                    }
258                }
259                penumbra_sdk_transaction::ActionView::Swap(swap) => {
260                    // Typical swaps are one asset for another, but we can't know that for sure.
261                    match swap {
262                        SwapView::Visible { swap_plaintext, .. } => {
263                            let (from_asset, from_value, to_asset) = match (
264                                swap_plaintext.delta_1_i.value(),
265                                swap_plaintext.delta_2_i.value(),
266                            ) {
267                                (0, v) if v > 0 => (
268                                    swap_plaintext.trading_pair.asset_2(),
269                                    swap_plaintext.delta_2_i,
270                                    swap_plaintext.trading_pair.asset_1(),
271                                ),
272                                (v, 0) if v > 0 => (
273                                    swap_plaintext.trading_pair.asset_1(),
274                                    swap_plaintext.delta_1_i,
275                                    swap_plaintext.trading_pair.asset_2(),
276                                ),
277                                // The pathological case (both assets have output values).
278                                _ => (
279                                    swap_plaintext.trading_pair.asset_1(),
280                                    swap_plaintext.delta_1_i,
281                                    swap_plaintext.trading_pair.asset_1(),
282                                ),
283                            };
284
285                            action = format!(
286                                "{} {} for {} and paid claim fee {}",
287                                from_value,
288                                format_asset_id(&from_asset),
289                                format_asset_id(&to_asset),
290                                format_fee(&swap_plaintext.claim_fee),
291                            );
292
293                            ["Swap", &action]
294                        }
295                        SwapView::Opaque { swap, .. } => {
296                            action = format!(
297                                "Opaque swap for trading pair: {} <=> {}",
298                                format_asset_id(&swap.body.trading_pair.asset_1()),
299                                format_asset_id(&swap.body.trading_pair.asset_2()),
300                            );
301                            ["Swap", &action]
302                        }
303                    }
304                }
305                penumbra_sdk_transaction::ActionView::SwapClaim(swap_claim) => {
306                    match swap_claim {
307                        SwapClaimView::Visible {
308                            swap_claim,
309                            output_1,
310                            output_2,
311                            swap_tx: _,
312                        } => {
313                            // View service can't see SwapClaims: https://github.com/penumbra-zone/penumbra/issues/2547
314                            dbg!(swap_claim);
315                            let claimed_value = match (
316                                value_view_amount(&output_1.value).value(),
317                                value_view_amount(&output_2.value).value(),
318                            ) {
319                                (0, v) if v > 0 => format_value_view(&output_2.value),
320                                (v, 0) if v > 0 => format_value_view(&output_1.value),
321                                // The pathological case (both assets have output values).
322                                _ => format!(
323                                    "{} and {}",
324                                    format_value_view(&output_1.value),
325                                    format_value_view(&output_2.value),
326                                ),
327                            };
328
329                            action = format!(
330                                "Claimed {} with fee {:?}",
331                                claimed_value,
332                                format_fee(&swap_claim.body.fee),
333                            );
334                            ["Swap Claim", &action]
335                        }
336                        SwapClaimView::Opaque { swap_claim } => {
337                            let bytes = swap_claim.body.nullifier.to_bytes(); // taken to be a unique value, for aesthetic reasons
338                            action = format_opaque_bytes(&bytes);
339                            ["Swap Claim", &action]
340                        }
341                    }
342                }
343                penumbra_sdk_transaction::ActionView::Ics20Withdrawal(withdrawal) => {
344                    let unit = withdrawal.denom.best_unit_for(withdrawal.amount);
345                    action = format!(
346                        "{}{} via {} to {}",
347                        unit.format_value(withdrawal.amount),
348                        unit,
349                        withdrawal.source_channel,
350                        withdrawal.destination_chain_address,
351                    );
352                    ["Ics20 Withdrawal", &action]
353                }
354                penumbra_sdk_transaction::ActionView::PositionOpen(position_open) => {
355                    let position = &position_open.position;
356                    /* TODO: leaving this around since we may want it to render prices
357                    let _unit_pair = DirectedUnitPair {
358                        start: unit_1.clone(),
359                        end: unit_2.clone(),
360                    };
361                    */
362
363                    action = format!(
364                        "Reserves: ({} {}, {} {}) Fee: {} ID: {}",
365                        position.reserves.r1,
366                        format_asset_id(&position.phi.pair.asset_1()),
367                        position.reserves.r2,
368                        format_asset_id(&position.phi.pair.asset_2()),
369                        position.phi.component.fee,
370                        position.id(),
371                    );
372                    ["Open Liquidity Position", &action]
373                }
374                penumbra_sdk_transaction::ActionView::PositionClose(_) => {
375                    ["Close Liquitity Position", ""]
376                }
377                penumbra_sdk_transaction::ActionView::PositionWithdraw(_) => {
378                    ["Withdraw Liquitity Position", ""]
379                }
380                penumbra_sdk_transaction::ActionView::ProposalDepositClaim(
381                    proposal_deposit_claim,
382                ) => {
383                    action = format!(
384                        "Claim Deposit for Governance Proposal #{}",
385                        proposal_deposit_claim.proposal
386                    );
387                    [&action, ""]
388                }
389                penumbra_sdk_transaction::ActionView::ProposalSubmit(proposal_submit) => {
390                    action = format!(
391                        "Submit Governance Proposal #{}",
392                        proposal_submit.proposal.id
393                    );
394                    [&action, ""]
395                }
396                penumbra_sdk_transaction::ActionView::ProposalWithdraw(proposal_withdraw) => {
397                    action = format!(
398                        "Withdraw Governance Proposal #{}",
399                        proposal_withdraw.proposal
400                    );
401                    [&action, ""]
402                }
403                penumbra_sdk_transaction::ActionView::IbcRelay(_) => ["IBC Relay", ""],
404                penumbra_sdk_transaction::ActionView::DelegatorVote(_) => ["Delegator Vote", ""],
405                penumbra_sdk_transaction::ActionView::ValidatorDefinition(_) => {
406                    ["Upload Validator Definition", ""]
407                }
408                penumbra_sdk_transaction::ActionView::ValidatorVote(_) => ["Validator Vote", ""],
409                penumbra_sdk_transaction::ActionView::CommunityPoolDeposit(_) => {
410                    ["Community Pool Deposit", ""]
411                }
412                penumbra_sdk_transaction::ActionView::CommunityPoolSpend(_) => {
413                    ["Community Pool Spend", ""]
414                }
415                penumbra_sdk_transaction::ActionView::CommunityPoolOutput(_) => {
416                    ["Community Pool Output", ""]
417                }
418                penumbra_sdk_transaction::ActionView::Delegate(_) => ["Delegation", ""],
419                penumbra_sdk_transaction::ActionView::Undelegate(_) => ["Undelegation", ""],
420                penumbra_sdk_transaction::ActionView::UndelegateClaim(_) => {
421                    ["Undelegation Claim", ""]
422                }
423                penumbra_sdk_transaction::ActionView::ActionDutchAuctionSchedule(x) => {
424                    let description = &x.action.description;
425
426                    let input: String = format_value_view(&create_value_view(
427                        description.input,
428                        x.input_metadata.clone(),
429                    ));
430                    let output: String = format_amount_range(
431                        description.min_output,
432                        description.max_output,
433                        &description.output_id,
434                        x.output_metadata.as_ref(),
435                    );
436                    let start = description.start_height;
437                    let stop = description.end_height;
438                    let steps = description.step_count;
439                    let auction_id = x.auction_id;
440                    action = format!(
441                        "{} -> {}, blocks {}..{}, in {} steps ({})",
442                        input, output, start, stop, steps, auction_id
443                    );
444                    ["Dutch Auction Schedule", &action]
445                }
446                penumbra_sdk_transaction::ActionView::ActionDutchAuctionEnd(x) => {
447                    action = format!("{}", x.auction_id);
448                    ["Dutch Auction End", &action]
449                }
450                penumbra_sdk_transaction::ActionView::ActionDutchAuctionWithdraw(x) => {
451                    let inside = x
452                        .reserves
453                        .iter()
454                        .map(|value| format_value_view(value))
455                        .collect::<Vec<_>>()
456                        .as_slice()
457                        .join(", ");
458                    action = format!("{} -> [{}]", x.action.auction_id, inside);
459                    ["Dutch Auction Withdraw", &action]
460                }
461            };
462
463            actions_table.add_row(row);
464        }
465
466        // Print table of actions and their descriptions
467        println!("{actions_table}");
468    }
469}