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