pcli/command/
tx.rs

1use std::{
2    collections::BTreeMap,
3    fs::{self, File},
4    io::{Read, Write},
5    path::PathBuf,
6    str::FromStr,
7    time::{SystemTime, UNIX_EPOCH},
8};
9
10use anyhow::{ensure, Context, Result};
11use decaf377::{Fq, Fr};
12use ibc_proto::ibc::core::client::v1::{
13    query_client::QueryClient as IbcClientQueryClient, QueryClientStateRequest,
14};
15use ibc_proto::ibc::core::connection::v1::query_client::QueryClient as IbcConnectionQueryClient;
16use ibc_proto::ibc::core::{
17    channel::v1::{query_client::QueryClient as IbcChannelQueryClient, QueryChannelRequest},
18    connection::v1::QueryConnectionRequest,
19};
20use ibc_types::core::{
21    channel::{ChannelId, PortId},
22    client::Height as IbcHeight,
23};
24use ibc_types::lightclients::tendermint::client_state::ClientState as TendermintClientState;
25use lqt_vote::LqtVoteCmd;
26use rand_core::OsRng;
27use regex::Regex;
28
29use liquidity_position::PositionCmd;
30use penumbra_sdk_asset::{asset, asset::Metadata, Value, STAKING_TOKEN_ASSET_ID};
31use penumbra_sdk_dex::{
32    lp::position::{self, Position, State},
33    swap_claim::SwapClaimPlan,
34};
35use penumbra_sdk_fee::FeeTier;
36use penumbra_sdk_governance::{
37    proposal::ProposalToml, proposal_state::State as ProposalState, Vote,
38};
39use penumbra_sdk_keys::{keys::AddressIndex, Address};
40use penumbra_sdk_num::Amount;
41use penumbra_sdk_proto::core::app::v1::{
42    query_service_client::QueryServiceClient as AppQueryServiceClient, AppParametersRequest,
43};
44use penumbra_sdk_proto::{
45    core::component::{
46        dex::v1::{
47            query_service_client::QueryServiceClient as DexQueryServiceClient,
48            LiquidityPositionByIdRequest, PositionId,
49        },
50        governance::v1::{
51            query_service_client::QueryServiceClient as GovernanceQueryServiceClient,
52            NextProposalIdRequest, ProposalDataRequest, ProposalInfoRequest, ProposalInfoResponse,
53            ProposalRateDataRequest,
54        },
55        sct::v1::{
56            query_service_client::QueryServiceClient as SctQueryServiceClient, EpochByHeightRequest,
57        },
58        stake::v1::{
59            query_service_client::QueryServiceClient as StakeQueryServiceClient,
60            ValidatorPenaltyRequest, ValidatorStatusRequest,
61        },
62    },
63    cosmos::tx::v1beta1::{
64        mode_info::{Single, Sum},
65        service_client::ServiceClient as CosmosServiceClient,
66        AuthInfo as CosmosAuthInfo, BroadcastTxRequest as CosmosBroadcastTxRequest,
67        Fee as CosmosFee, ModeInfo, SignerInfo as CosmosSignerInfo, Tx as CosmosTx,
68        TxBody as CosmosTxBody,
69    },
70    noble::forwarding::v1::{ForwardingPubKey, MsgRegisterAccount},
71    view::v1::GasPricesRequest,
72    Message, Name as _,
73};
74use penumbra_sdk_shielded_pool::Ics20Withdrawal;
75use penumbra_sdk_stake::{
76    rate::RateData,
77    validator::{self},
78};
79use penumbra_sdk_stake::{
80    DelegationToken, IdentityKey, Penalty, UnbondingToken, UndelegateClaimPlan,
81};
82use penumbra_sdk_transaction::{gas::swap_claim_gas_cost, Transaction};
83use penumbra_sdk_view::{SpendableNoteRecord, ViewClient};
84use penumbra_sdk_wallet::plan::{self, Planner};
85use proposal::ProposalCmd;
86use tonic::transport::{Channel, ClientTlsConfig};
87use url::Url;
88
89use crate::command::tx::auction::AuctionCmd;
90use crate::App;
91use clap::Parser;
92
93mod auction;
94mod liquidity_position;
95mod lqt_vote;
96mod proposal;
97mod replicate;
98
99/// The planner can fail to build a large transaction, so
100/// pcli splits apart the number of positions to close/withdraw
101/// in the [`PositionCmd::CloseAll`]/[`PositionCmd::WithdrawAll`] commands.
102const POSITION_CHUNK_SIZE: usize = 30;
103
104#[derive(Debug, Parser)]
105pub struct TxCmdWithOptions {
106    /// If present, a file to save the transaction to instead of broadcasting it
107    #[clap(long)]
108    pub offline: Option<PathBuf>,
109    #[clap(subcommand)]
110    pub cmd: TxCmd,
111}
112
113impl TxCmdWithOptions {
114    /// Determine if this command requires a network sync before it executes.
115    pub fn offline(&self) -> bool {
116        self.cmd.offline()
117    }
118
119    pub async fn exec(&self, app: &mut App) -> Result<()> {
120        app.save_transaction_here_instead = self.offline.clone();
121        self.cmd.exec(app).await
122    }
123}
124
125#[derive(Debug, clap::Subcommand)]
126pub enum TxCmd {
127    /// Auction related commands.
128    #[clap(display_order = 600, subcommand)]
129    Auction(AuctionCmd),
130    /// Send funds to a Penumbra address.
131    #[clap(display_order = 100)]
132    Send {
133        /// The destination address to send funds to.
134        #[clap(long, display_order = 100)]
135        to: String,
136        /// The amounts to send, written as typed values 1.87penumbra, 12cubes, etc.
137        values: Vec<String>,
138        /// Only spend funds originally received by the given account.
139        #[clap(long, default_value = "0", display_order = 300)]
140        source: u32,
141        /// Optional. Set the transaction's memo field to the provided text.
142        #[clap(long)]
143        memo: Option<String>,
144        /// The selected fee tier to multiply the fee amount by.
145        #[clap(short, long, default_value_t)]
146        fee_tier: FeeTier,
147    },
148    /// Deposit stake into a validator's delegation pool.
149    #[clap(display_order = 200)]
150    Delegate {
151        /// The identity key of the validator to delegate to.
152        #[clap(long, display_order = 100)]
153        to: String,
154        /// The amount of stake to delegate.
155        amount: String,
156        /// Only spend funds originally received by the given account.
157        #[clap(long, default_value = "0", display_order = 300)]
158        source: u32,
159        /// The selected fee tier to multiply the fee amount by.
160        #[clap(short, long, default_value_t)]
161        fee_tier: FeeTier,
162    },
163    /// Delegate to many validators in a single transaction.
164    #[clap(display_order = 200)]
165    DelegateMany {
166        /// A path to a CSV file of (validator identity, UM amount) pairs.
167        ///
168        /// The amount is in UM, not upenumbra.
169        #[clap(long, display_order = 100)]
170        csv_path: String,
171        /// Only spend funds originally received by the given account.
172        #[clap(long, default_value = "0", display_order = 300)]
173        source: u32,
174        /// The selected fee tier to multiply the fee amount by.
175        #[clap(short, long, default_value_t)]
176        fee_tier: FeeTier,
177    },
178    /// Withdraw stake from a validator's delegation pool.
179    #[clap(display_order = 200)]
180    Undelegate {
181        /// The amounts of delegation tokens to undelegate.
182        #[clap(required = true)]
183        amounts: Vec<String>,
184        /// Only spend funds originally received by the given account.
185        #[clap(long, default_value = "0", display_order = 300)]
186        source: u32,
187        /// The selected fee tier to multiply the fee amount by.
188        #[clap(short, long, default_value_t)]
189        fee_tier: FeeTier,
190    },
191    /// Claim any undelegations that have finished unbonding.
192    #[clap(display_order = 200)]
193    UndelegateClaim {
194        /// The selected fee tier to multiply the fee amount by.
195        #[clap(short, long, default_value_t)]
196        fee_tier: FeeTier,
197    },
198    /// Swap tokens of one denomination for another using the DEX.
199    ///
200    /// Swaps are batched and executed at the market-clearing price.
201    ///
202    /// A swap generates two transactions: an initial "swap" transaction that
203    /// submits the swap, and a "swap claim" transaction that privately mints
204    /// the output funds once the batch has executed.  The second transaction
205    /// will be created and submitted automatically.
206    #[clap(display_order = 300)]
207    Swap {
208        /// The input amount to swap, written as a typed value 1.87penumbra, 12cubes, etc.
209        input: String,
210        /// The denomination to swap the input into, e.g. `gm`
211        #[clap(long, display_order = 100)]
212        into: String,
213        /// Only spend funds originally received by the given account.
214        #[clap(long, default_value = "0", display_order = 300)]
215        source: u32,
216        /// The selected fee tier to multiply the fee amount by.
217        #[clap(short, long, default_value_t)]
218        fee_tier: FeeTier,
219    },
220    /// Vote on a governance proposal in your role as a delegator (see also: `pcli validator vote`).
221    #[clap(display_order = 400)]
222    Vote {
223        /// Only spend funds and vote with staked delegation tokens originally received by the given
224        /// account.
225        #[clap(long, default_value = "0", global = true, display_order = 300)]
226        source: u32,
227        #[clap(subcommand)]
228        vote: VoteCmd,
229        /// The selected fee tier to multiply the fee amount by.
230        #[clap(short, long, default_value_t)]
231        fee_tier: FeeTier,
232    },
233    /// Submit or withdraw a governance proposal.
234    #[clap(display_order = 500, subcommand)]
235    Proposal(ProposalCmd),
236    /// Deposit funds into the Community Pool.
237    #[clap(display_order = 600)]
238    CommunityPoolDeposit {
239        /// The amounts to send, written as typed values 1.87penumbra, 12cubes, etc.
240        #[clap(min_values = 1, required = true)]
241        values: Vec<String>,
242        /// Only spend funds originally received by the given account.
243        #[clap(long, default_value = "0", display_order = 300)]
244        source: u32,
245        /// The selected fee tier to multiply the fee amount by.
246        #[clap(short, long, default_value_t)]
247        fee_tier: FeeTier,
248    },
249    /// Manage liquidity positions.
250    #[clap(display_order = 500, subcommand, visible_alias = "lp")]
251    Position(PositionCmd),
252    /// Consolidate many small notes into a few larger notes.
253    ///
254    /// Since Penumbra transactions reveal their arity (how many spends,
255    /// outputs, etc), but transactions are unlinkable from each other, it is
256    /// slightly preferable to sweep small notes into larger ones in an isolated
257    /// "sweep" transaction, rather than at the point that they should be spent.
258    ///
259    /// Currently, only zero-fee sweep transactions are implemented.
260    #[clap(display_order = 990)]
261    Sweep,
262
263    /// Perform an ICS-20 withdrawal, moving funds from the Penumbra chain
264    /// to a counterparty chain.
265    ///
266    /// For a withdrawal to be processed on the counterparty, IBC packets must be relayed between
267    /// the two chains. Relaying is out of scope for the `pcli` tool.
268    #[clap(display_order = 250)]
269    Withdraw {
270        /// Address on the receiving chain,
271        /// e.g. cosmos1grgelyng2v6v3t8z87wu3sxgt9m5s03xvslewd. The chain_id for the counterparty
272        /// chain will be discovered automatically, based on the `--channel` setting.
273        #[clap(long)]
274        to: String,
275        /// The value to withdraw, eg "1000upenumbra"
276        value: String,
277        /// The IBC channel on the primary Penumbra chain to use for performing the withdrawal.
278        /// This channel must already exist, as configured by a relayer client.
279        /// You can search for channels via e.g. `pcli query ibc channel transfer 0`.
280        #[clap(long)]
281        channel: u64,
282        /// Block height on the counterparty chain, after which the withdrawal will be considered
283        /// invalid if not already relayed. Must be specified as a tuple of revision number and block
284        /// height, e.g. `5-1000000` means "chain revision 5, block height of 1000000".
285        /// You must know the chain id of the counterparty chain beforehand, e.g. `osmosis-testnet-5`,
286        /// to know the revision number.
287        #[clap(long, display_order = 100)]
288        timeout_height: Option<IbcHeight>,
289        /// Timestamp, specified in epoch time, after which the withdrawal will be considered
290        /// invalid if not already relayed.
291        #[clap(long, default_value = "0", display_order = 150)]
292        timeout_timestamp: u64,
293        /// Only withdraw funds from the specified wallet id within Penumbra.
294        #[clap(long, default_value = "0", display_order = 200)]
295        source: u32,
296        /// Optional. Set the IBC ICS-20 packet memo field to the provided text.
297        #[clap(long)]
298        memo: Option<String>,
299        /// The selected fee tier to multiply the fee amount by.
300        #[clap(short, long, default_value_t)]
301        fee_tier: FeeTier,
302        /// Whether to use a transparent address (bech32, 32-byte) for
303        /// the return address in the withdrawal.
304        /// Required for some chains for a successful acknowledgement.
305        #[clap(long)]
306        use_transparent_address: bool,
307    },
308    #[clap(display_order = 970)]
309    /// Register a Noble forwarding account.
310    RegisterForwardingAccount {
311        /// The Noble node to submit the registration transaction to.
312        #[clap(long)]
313        noble_node: Url,
314        /// The Noble IBC channel to use for forwarding.
315        #[clap(long)]
316        channel: String,
317        /// The Penumbra address or address index to receive forwarded funds.
318        #[clap(long)]
319        address_or_index: String,
320        /// Whether or not to use an ephemeral address.
321        #[clap(long)]
322        ephemeral: bool,
323    },
324    /// Broadcast a saved transaction to the network
325    #[clap(display_order = 1000)]
326    Broadcast {
327        /// The transaction to be broadcast
328        transaction: PathBuf,
329    },
330    #[clap(display_order = 700)]
331    LqtVote(LqtVoteCmd),
332}
333
334/// Vote on a governance proposal.
335#[derive(Debug, Clone, Copy, clap::Subcommand)]
336pub enum VoteCmd {
337    /// Vote in favor of a proposal.
338    #[clap(display_order = 100)]
339    Yes {
340        /// The proposal ID to vote on.
341        #[clap(long = "on")]
342        proposal_id: u64,
343    },
344    /// Vote against a proposal.
345    #[clap(display_order = 200)]
346    No {
347        /// The proposal ID to vote on.
348        #[clap(long = "on")]
349        proposal_id: u64,
350    },
351    /// Abstain from voting on a proposal.
352    #[clap(display_order = 300)]
353    Abstain {
354        /// The proposal ID to vote on.
355        #[clap(long = "on")]
356        proposal_id: u64,
357    },
358}
359
360impl From<VoteCmd> for (u64, Vote) {
361    fn from(cmd: VoteCmd) -> (u64, Vote) {
362        match cmd {
363            VoteCmd::Yes { proposal_id } => (proposal_id, Vote::Yes),
364            VoteCmd::No { proposal_id } => (proposal_id, Vote::No),
365            VoteCmd::Abstain { proposal_id } => (proposal_id, Vote::Abstain),
366        }
367    }
368}
369
370impl TxCmd {
371    /// Determine if this command requires a network sync before it executes.
372    pub fn offline(&self) -> bool {
373        match self {
374            TxCmd::Send { .. } => false,
375            TxCmd::Sweep { .. } => false,
376            TxCmd::Swap { .. } => false,
377            TxCmd::Delegate { .. } => false,
378            TxCmd::DelegateMany { .. } => false,
379            TxCmd::Undelegate { .. } => false,
380            TxCmd::UndelegateClaim { .. } => false,
381            TxCmd::Vote { .. } => false,
382            TxCmd::Proposal(proposal_cmd) => proposal_cmd.offline(),
383            TxCmd::CommunityPoolDeposit { .. } => false,
384            TxCmd::Position(lp_cmd) => lp_cmd.offline(),
385            TxCmd::Withdraw { .. } => false,
386            TxCmd::Auction(_) => false,
387            TxCmd::Broadcast { .. } => false,
388            TxCmd::RegisterForwardingAccount { .. } => false,
389            TxCmd::LqtVote(cmd) => cmd.offline(),
390        }
391    }
392
393    pub async fn exec(&self, app: &mut App) -> Result<()> {
394        // TODO: use a command line flag to determine the fee token,
395        // and pull the appropriate GasPrices out of this rpc response,
396        // the rest should follow
397        // TODO: fetching this here means that no tx commands
398        // can be run in offline mode, which is a bit annoying
399        let gas_prices = app
400            .view
401            .as_mut()
402            .context("view service must be initialized")?
403            .gas_prices(GasPricesRequest {})
404            .await?
405            .into_inner()
406            .gas_prices
407            .expect("gas prices must be available")
408            .try_into()?;
409
410        match self {
411            TxCmd::Send {
412                values,
413                to,
414                source: from,
415                memo,
416                fee_tier,
417            } => {
418                // Parse all of the values provided.
419                let values = values
420                    .iter()
421                    .map(|v| v.parse())
422                    .collect::<Result<Vec<Value>, _>>()?;
423                let to = to
424                    .parse::<Address>()
425                    .map_err(|_| anyhow::anyhow!("address is invalid"))?;
426
427                let mut planner = Planner::new(OsRng);
428
429                planner
430                    .set_gas_prices(gas_prices)
431                    .set_fee_tier((*fee_tier).into());
432                for value in values.iter().cloned() {
433                    planner.output(value, to.clone());
434                }
435                let plan = planner
436                    .memo(memo.clone().unwrap_or_default())
437                    .plan(
438                        app.view
439                            .as_mut()
440                            .context("view service must be initialized")?,
441                        AddressIndex::new(*from),
442                    )
443                    .await
444                    .context("can't build send transaction")?;
445                app.build_and_submit_transaction(plan).await?;
446            }
447            TxCmd::CommunityPoolDeposit {
448                values,
449                source,
450                fee_tier,
451            } => {
452                let values = values
453                    .iter()
454                    .map(|v| v.parse())
455                    .collect::<Result<Vec<Value>, _>>()?;
456
457                let mut planner = Planner::new(OsRng);
458                planner
459                    .set_gas_prices(gas_prices)
460                    .set_fee_tier((*fee_tier).into());
461                for value in values {
462                    planner.community_pool_deposit(value);
463                }
464                let plan = planner
465                    .plan(
466                        app.view
467                            .as_mut()
468                            .context("view service must be initialized")?,
469                        AddressIndex::new(*source),
470                    )
471                    .await?;
472                app.build_and_submit_transaction(plan).await?;
473            }
474            TxCmd::Sweep => loop {
475                let plans = plan::sweep(
476                    app.view
477                        .as_mut()
478                        .context("view service must be initialized")?,
479                    OsRng,
480                )
481                .await?;
482                let num_plans = plans.len();
483
484                for (i, plan) in plans.into_iter().enumerate() {
485                    println!("building sweep {i} of {num_plans}");
486                    app.build_and_submit_transaction(plan).await?;
487                }
488                if num_plans == 0 {
489                    println!("finished sweeping");
490                    break;
491                }
492            },
493            TxCmd::Swap {
494                input,
495                into,
496                source,
497                fee_tier,
498            } => {
499                let input = input.parse::<Value>()?;
500                let into = asset::REGISTRY.parse_unit(into.as_str()).base();
501                let fee_tier: FeeTier = (*fee_tier).into();
502
503                let fvk = app.config.full_viewing_key.clone();
504
505                // If a source address was specified, use it for the swap, otherwise,
506                // use the default address.
507                let (claim_address, _dtk_d) =
508                    fvk.incoming().payment_address(AddressIndex::new(*source));
509
510                let mut planner = Planner::new(OsRng);
511                planner
512                    .set_gas_prices(gas_prices.clone())
513                    .set_fee_tier(fee_tier.into());
514
515                // We don't expect much of a drift in gas prices in a few blocks, and the fee tier
516                // adjustments should be enough to cover it.
517                let estimated_claim_fee = gas_prices
518                    .fee(&swap_claim_gas_cost())
519                    .apply_tier(fee_tier.into());
520
521                planner.swap(input, into.id(), estimated_claim_fee, claim_address)?;
522
523                let plan = planner
524                    .plan(app.view(), AddressIndex::new(*source))
525                    .await
526                    .context("can't plan swap transaction")?;
527
528                // Hold on to the swap plaintext to be able to claim.
529                let swap_plaintext = plan
530                    .swap_plans()
531                    .next()
532                    .expect("swap plan must be present")
533                    .swap_plaintext
534                    .clone();
535
536                // Submit the `Swap` transaction, waiting for confirmation,
537                // at which point the swap will be available for claiming.
538                app.build_and_submit_transaction(plan).await?;
539
540                // Fetch the SwapRecord with the claimable swap.
541                let swap_record = app
542                    .view()
543                    .swap_by_commitment(swap_plaintext.swap_commitment())
544                    .await?;
545
546                let asset_cache = app.view().assets().await?;
547
548                let pro_rata_outputs = swap_record
549                    .output_data
550                    .pro_rata_outputs((swap_plaintext.delta_1_i, swap_plaintext.delta_2_i));
551                println!("Swap submitted and batch confirmed!");
552                println!(
553                    "You will receive outputs of {} and {}. Claiming now...",
554                    Value {
555                        amount: pro_rata_outputs.0,
556                        asset_id: swap_record.output_data.trading_pair.asset_1(),
557                    }
558                    .format(&asset_cache),
559                    Value {
560                        amount: pro_rata_outputs.1,
561                        asset_id: swap_record.output_data.trading_pair.asset_2(),
562                    }
563                    .format(&asset_cache),
564                );
565
566                let params = app
567                    .view
568                    .as_mut()
569                    .context("view service must be initialized")?
570                    .app_params()
571                    .await?;
572
573                let mut planner = Planner::new(OsRng);
574                planner
575                    .set_gas_prices(gas_prices)
576                    .set_fee_tier(fee_tier.into());
577                let plan = planner
578                    .swap_claim(SwapClaimPlan {
579                        swap_plaintext,
580                        position: swap_record.position,
581                        output_data: swap_record.output_data,
582                        epoch_duration: params.sct_params.epoch_duration,
583                        proof_blinding_r: Fq::rand(&mut OsRng),
584                        proof_blinding_s: Fq::rand(&mut OsRng),
585                    })
586                    .plan(app.view(), AddressIndex::new(*source))
587                    .await
588                    .context("can't plan swap claim")?;
589
590                // Submit the `SwapClaim` transaction.
591                // BUG: this doesn't wait for confirmation, see
592                // https://github.com/penumbra-zone/penumbra/pull/2091/commits/128b24a6303c2f855a708e35f9342987f1dd34ec
593                app.build_and_submit_transaction(plan).await?;
594            }
595            TxCmd::Delegate {
596                to,
597                amount,
598                source,
599                fee_tier,
600            } => {
601                let unbonded_amount = {
602                    let Value { amount, asset_id } = amount.parse::<Value>()?;
603                    if asset_id != *STAKING_TOKEN_ASSET_ID {
604                        anyhow::bail!("staking can only be done with the staking token");
605                    }
606                    amount
607                };
608
609                let to = to.parse::<IdentityKey>()?;
610
611                let mut stake_client = StakeQueryServiceClient::new(app.pd_channel().await?);
612                let rate_data: RateData = stake_client
613                    .current_validator_rate(tonic::Request::new(to.into()))
614                    .await?
615                    .into_inner()
616                    .try_into()?;
617
618                let mut sct_client = SctQueryServiceClient::new(app.pd_channel().await?);
619                let latest_sync_height = app.view().status().await?.full_sync_height;
620                let epoch = sct_client
621                    .epoch_by_height(EpochByHeightRequest {
622                        height: latest_sync_height,
623                    })
624                    .await?
625                    .into_inner()
626                    .epoch
627                    .expect("epoch must be available")
628                    .into();
629
630                let mut planner = Planner::new(OsRng);
631                planner
632                    .set_gas_prices(gas_prices)
633                    .set_fee_tier((*fee_tier).into());
634                let plan = planner
635                    .delegate(epoch, unbonded_amount, rate_data)
636                    .plan(app.view(), AddressIndex::new(*source))
637                    .await
638                    .context("can't plan delegation, try running pcli tx sweep and try again")?;
639
640                app.build_and_submit_transaction(plan).await?;
641            }
642            TxCmd::DelegateMany {
643                csv_path,
644                source,
645                fee_tier,
646            } => {
647                let mut stake_client = StakeQueryServiceClient::new(app.pd_channel().await?);
648
649                let mut sct_client = SctQueryServiceClient::new(app.pd_channel().await?);
650                let latest_sync_height = app.view().status().await?.full_sync_height;
651                let epoch = sct_client
652                    .epoch_by_height(EpochByHeightRequest {
653                        height: latest_sync_height,
654                    })
655                    .await?
656                    .into_inner()
657                    .epoch
658                    .expect("epoch must be available")
659                    .into();
660
661                let mut planner = Planner::new(OsRng);
662                planner
663                    .set_gas_prices(gas_prices)
664                    .set_fee_tier((*fee_tier).into());
665
666                let file = File::open(csv_path).context("can't open CSV file")?;
667                let mut reader = csv::ReaderBuilder::new()
668                    .has_headers(false) // Don't skip any rows
669                    .from_reader(file);
670                for result in reader.records() {
671                    let record = result?;
672                    let validator_identity: IdentityKey = record[0].parse()?;
673
674                    let rate_data: RateData = stake_client
675                        .current_validator_rate(tonic::Request::new(validator_identity.into()))
676                        .await?
677                        .into_inner()
678                        .try_into()?;
679
680                    let typed_amount_str = format!("{}penumbra", &record[1]);
681
682                    let unbonded_amount = {
683                        let Value { amount, asset_id } = typed_amount_str.parse::<Value>()?;
684                        if asset_id != *STAKING_TOKEN_ASSET_ID {
685                            anyhow::bail!("staking can only be done with the staking token");
686                        }
687                        amount
688                    };
689
690                    planner.delegate(epoch, unbonded_amount, rate_data);
691                }
692
693                let plan = planner
694                    .plan(app.view(), AddressIndex::new(*source))
695                    .await
696                    .context("can't plan delegation, try running pcli tx sweep and try again")?;
697
698                app.build_and_submit_transaction(plan).await?;
699            }
700            TxCmd::Undelegate {
701                amounts,
702                source,
703                fee_tier,
704            } => {
705                let mut sct_client = SctQueryServiceClient::new(app.pd_channel().await?);
706                let latest_sync_height = app.view().status().await?.full_sync_height;
707                let epoch = sct_client
708                    .epoch_by_height(EpochByHeightRequest {
709                        height: latest_sync_height,
710                    })
711                    .await?
712                    .into_inner()
713                    .epoch
714                    .expect("epoch must be available")
715                    .into();
716
717                let mut planner = Planner::new(OsRng);
718                planner
719                    .set_gas_prices(gas_prices)
720                    .set_fee_tier((*fee_tier).into());
721
722                let mut stake_client = StakeQueryServiceClient::new(app.pd_channel().await?);
723
724                for amount in amounts {
725                    let delegation_value @ Value {
726                        amount: _,
727                        asset_id,
728                    } = amount.parse::<Value>()?;
729
730                    // TODO: it's awkward that we can't just pull the denom out of the `amount` string we were already given
731                    let delegation_token: DelegationToken = app
732                        .view()
733                        .assets()
734                        .await?
735                        .get(&asset_id)
736                        .ok_or_else(|| anyhow::anyhow!("unknown asset id {}", asset_id))?
737                        .clone()
738                        .try_into()
739                        .context("could not parse supplied denomination as a delegation token")?;
740
741                    let from = delegation_token.validator();
742
743                    let rate_data: RateData = stake_client
744                        .current_validator_rate(tonic::Request::new(from.into()))
745                        .await?
746                        .into_inner()
747                        .try_into()?;
748
749                    planner.undelegate(epoch, delegation_value.amount, rate_data);
750                }
751
752                let plan = planner
753                    .plan(
754                        app.view
755                            .as_mut()
756                            .context("view service must be initialized")?,
757                        AddressIndex::new(*source),
758                    )
759                    .await
760                    .context("can't build undelegate plan")?;
761
762                app.build_and_submit_transaction(plan).await?;
763            }
764            TxCmd::UndelegateClaim { fee_tier } => {
765                let channel = app.pd_channel().await?;
766                let view: &mut dyn ViewClient = app
767                    .view
768                    .as_mut()
769                    .context("view service must be initialized")?;
770
771                let current_height = view.status().await?.full_sync_height;
772                let asset_cache = view.assets().await?;
773
774                // Query the view client for the list of undelegations that are ready to be claimed.
775                // We want to claim them into the same address index that currently holds the tokens.
776                let notes = view.unspent_notes_by_address_and_asset().await?;
777
778                let notes: Vec<(
779                    AddressIndex,
780                    Vec<(UnbondingToken, Vec<SpendableNoteRecord>)>,
781                )> = notes
782                    .into_iter()
783                    .map(|(address_index, notes_by_asset)| {
784                        let mut filtered_notes: Vec<(UnbondingToken, Vec<SpendableNoteRecord>)> =
785                            notes_by_asset
786                                .into_iter()
787                                .filter_map(|(asset_id, notes)| {
788                                    // Filter for notes that are unbonding tokens.
789                                    let denom = asset_cache
790                                        .get(&asset_id)
791                                        .expect("asset ID should exist in asset cache")
792                                        .clone();
793                                    match UnbondingToken::try_from(denom) {
794                                        Ok(token) => Some((token, notes)),
795                                        Err(_) => None,
796                                    }
797                                })
798                                .collect();
799
800                        filtered_notes.sort_by_key(|(token, _)| token.unbonding_start_height());
801
802                        (address_index, filtered_notes)
803                    })
804                    .collect();
805
806                for (address_index, notes_by_asset) in notes.into_iter() {
807                    for (token, notes) in notes_by_asset.into_iter() {
808                        println!("claiming {}", token.denom().default_unit());
809
810                        let validator_identity = token.validator();
811                        let unbonding_start_height = token.unbonding_start_height();
812
813                        let mut app_client = AppQueryServiceClient::new(channel.clone());
814                        let mut stake_client = StakeQueryServiceClient::new(channel.clone());
815                        let mut sct_client = SctQueryServiceClient::new(channel.clone());
816
817                        let min_block_delay = app_client
818                            .app_parameters(AppParametersRequest {})
819                            .await?
820                            .into_inner()
821                            .app_parameters
822                            .expect("app parameters must be available")
823                            .stake_params
824                            .expect("stake params must be available")
825                            .unbonding_delay;
826
827                        // Fetch the validator pool's state at present:
828                        let bonding_state = stake_client
829                            .validator_status(ValidatorStatusRequest {
830                                identity_key: Some(validator_identity.into()),
831                            })
832                            .await?
833                            .into_inner()
834                            .status
835                            .context("unable to get validator status")?
836                            .bonding_state
837                            .expect("bonding state must be available")
838                            .try_into()
839                            .expect("valid bonding state");
840
841                        let upper_bound_block_delay = unbonding_start_height + min_block_delay;
842
843                        // We have to be cautious to compute the penalty over the exact range of epochs
844                        // because we could be processing old unbonding tokens that are bound to a validator
845                        // that transitioned to a variety of states, incurring penalties that do not apply
846                        // to these tokens.
847                        // We can replace this with a single gRPC call to the staking component.
848                        // For now, this is sufficient.
849                        let unbonding_height = match bonding_state {
850                            validator::BondingState::Bonded => upper_bound_block_delay,
851                            validator::BondingState::Unbonding { unbonds_at_height } => {
852                                if unbonds_at_height > unbonding_start_height {
853                                    unbonds_at_height.min(upper_bound_block_delay)
854                                } else {
855                                    current_height
856                                }
857                            }
858                            validator::BondingState::Unbonded => current_height,
859                        };
860
861                        // if the unbonding height is in the future we clamp to the current height:
862                        let unbonding_height = unbonding_height.min(current_height);
863
864                        let start_epoch_index = sct_client
865                            .epoch_by_height(EpochByHeightRequest {
866                                height: unbonding_start_height,
867                            })
868                            .await
869                            .expect("can get epoch by height")
870                            .into_inner()
871                            .epoch
872                            .context("unable to get epoch for unbonding start height")?
873                            .index;
874
875                        let end_epoch_index = sct_client
876                            .epoch_by_height(EpochByHeightRequest {
877                                height: unbonding_height,
878                            })
879                            .await
880                            .expect("can get epoch by height")
881                            .into_inner()
882                            .epoch
883                            .context("unable to get epoch for unbonding end height")?
884                            .index;
885
886                        let penalty: Penalty = stake_client
887                            .validator_penalty(tonic::Request::new(ValidatorPenaltyRequest {
888                                identity_key: Some(validator_identity.into()),
889                                start_epoch_index,
890                                end_epoch_index,
891                            }))
892                            .await?
893                            .into_inner()
894                            .penalty
895                            .ok_or_else(|| {
896                                anyhow::anyhow!(
897                                    "no penalty returned for validator {}",
898                                    validator_identity
899                                )
900                            })?
901                            .try_into()?;
902
903                        let mut planner = Planner::new(OsRng);
904                        planner
905                            .set_gas_prices(gas_prices.clone())
906                            .set_fee_tier((*fee_tier).into());
907                        let unbonding_amount = notes.iter().map(|n| n.note.amount()).sum();
908
909                        let plan = planner
910                            .undelegate_claim(UndelegateClaimPlan {
911                                validator_identity,
912                                unbonding_start_height,
913                                penalty,
914                                unbonding_amount,
915                                balance_blinding: Fr::rand(&mut OsRng),
916                                proof_blinding_r: Fq::rand(&mut OsRng),
917                                proof_blinding_s: Fq::rand(&mut OsRng),
918                            })
919                            .plan(
920                                app.view
921                                    .as_mut()
922                                    .context("view service must be initialized")?,
923                                address_index,
924                            )
925                            .await?;
926                        app.build_and_submit_transaction(plan).await?;
927                    }
928                }
929            }
930            TxCmd::Proposal(ProposalCmd::Submit {
931                file,
932                source,
933                deposit_amount,
934                fee_tier,
935            }) => {
936                let mut proposal_file = File::open(file).context("can't open proposal file")?;
937                let mut proposal_string = String::new();
938                proposal_file
939                    .read_to_string(&mut proposal_string)
940                    .context("can't read proposal file")?;
941                let proposal_toml: ProposalToml =
942                    toml::from_str(&proposal_string).context("can't parse proposal file")?;
943                let proposal = proposal_toml
944                    .try_into()
945                    .context("can't parse proposal file")?;
946
947                let deposit_amount: Value = deposit_amount.parse()?;
948                ensure!(
949                    deposit_amount.asset_id == *STAKING_TOKEN_ASSET_ID,
950                    "deposit amount must be in staking token"
951                );
952
953                let mut planner = Planner::new(OsRng);
954                planner
955                    .set_gas_prices(gas_prices)
956                    .set_fee_tier((*fee_tier).into());
957                let plan = planner
958                    .proposal_submit(proposal, deposit_amount.amount)
959                    .plan(
960                        app.view
961                            .as_mut()
962                            .context("view service must be initialized")?,
963                        AddressIndex::new(*source),
964                    )
965                    .await?;
966                app.build_and_submit_transaction(plan).await?;
967            }
968            TxCmd::Proposal(ProposalCmd::Withdraw {
969                proposal_id,
970                reason,
971                source,
972                fee_tier,
973            }) => {
974                let mut planner = Planner::new(OsRng);
975                planner
976                    .set_gas_prices(gas_prices)
977                    .set_fee_tier((*fee_tier).into());
978                let plan = planner
979                    .proposal_withdraw(*proposal_id, reason.clone())
980                    .plan(
981                        app.view
982                            .as_mut()
983                            .context("view service must be initialized")?,
984                        AddressIndex::new(*source),
985                    )
986                    .await?;
987
988                app.build_and_submit_transaction(plan).await?;
989            }
990            TxCmd::Proposal(ProposalCmd::Template { file, kind }) => {
991                let app_params = app.view().app_params().await?;
992
993                // Find out what the latest proposal ID is so we can include the next ID in the template:
994                let mut client = GovernanceQueryServiceClient::new(app.pd_channel().await?);
995                let next_proposal_id: u64 = client
996                    .next_proposal_id(NextProposalIdRequest {})
997                    .await?
998                    .into_inner()
999                    .next_proposal_id;
1000
1001                let toml_template: ProposalToml = kind
1002                    .template_proposal(&app_params, next_proposal_id)?
1003                    .into();
1004
1005                if let Some(file) = file {
1006                    File::create(file)
1007                        .with_context(|| format!("cannot create file {file:?}"))?
1008                        .write_all(toml::to_string_pretty(&toml_template)?.as_bytes())
1009                        .context("could not write file")?;
1010                } else {
1011                    println!("{}", toml::to_string_pretty(&toml_template)?);
1012                }
1013            }
1014            TxCmd::Proposal(ProposalCmd::DepositClaim {
1015                proposal_id,
1016                source,
1017                fee_tier,
1018            }) => {
1019                let mut client = GovernanceQueryServiceClient::new(app.pd_channel().await?);
1020                let proposal = client
1021                    .proposal_data(ProposalDataRequest {
1022                        proposal_id: *proposal_id,
1023                    })
1024                    .await?
1025                    .into_inner();
1026                let state: ProposalState = proposal
1027                    .state
1028                    .context(format!(
1029                        "proposal state for proposal {} was not found",
1030                        proposal_id
1031                    ))?
1032                    .try_into()?;
1033                let deposit_amount: Amount = proposal
1034                    .proposal_deposit_amount
1035                    .context(format!(
1036                        "proposal deposit amount for proposal {} was not found",
1037                        proposal_id
1038                    ))?
1039                    .try_into()?;
1040
1041                let outcome = match state {
1042                    ProposalState::Voting => anyhow::bail!(
1043                        "proposal {} is still voting, so the deposit cannot yet be claimed",
1044                        proposal_id
1045                    ),
1046                    ProposalState::Withdrawn { reason: _ } => {
1047                        anyhow::bail!("proposal {} has been withdrawn but voting has not yet concluded, so the deposit cannot yet be claimed", proposal_id);
1048                    }
1049                    ProposalState::Finished { outcome } => outcome.map(|_| ()),
1050                    ProposalState::Claimed { outcome: _ } => {
1051                        anyhow::bail!("proposal {} has already been claimed", proposal_id)
1052                    }
1053                };
1054
1055                let plan = Planner::new(OsRng)
1056                    .set_gas_prices(gas_prices)
1057                    .set_fee_tier((*fee_tier).into())
1058                    .proposal_deposit_claim(*proposal_id, deposit_amount, outcome)
1059                    .plan(
1060                        app.view
1061                            .as_mut()
1062                            .context("view service must be initialized")?,
1063                        AddressIndex::new(*source),
1064                    )
1065                    .await?;
1066
1067                app.build_and_submit_transaction(plan).await?;
1068            }
1069            TxCmd::Vote {
1070                vote,
1071                source,
1072                fee_tier,
1073            } => {
1074                let (proposal_id, vote): (u64, Vote) = (*vote).into();
1075
1076                // Before we vote on the proposal, we have to gather some information about it so
1077                // that we can prepare our vote:
1078                // - the start height, so we can select the votable staked notes to vote with
1079                // - the start position, so we can submit the appropriate public `start_position`
1080                //   input for stateless proof verification
1081                // - the rate data for every validator at the start of the proposal, so we can
1082                //   convert staked notes into voting power and mint the correct amount of voting
1083                //   receipt tokens to ourselves
1084
1085                let mut client = GovernanceQueryServiceClient::new(app.pd_channel().await?);
1086                let ProposalInfoResponse {
1087                    start_block_height,
1088                    start_position,
1089                } = client
1090                    .proposal_info(ProposalInfoRequest { proposal_id })
1091                    .await?
1092                    .into_inner();
1093                let start_position = start_position.into();
1094
1095                let mut rate_data_stream = client
1096                    .proposal_rate_data(ProposalRateDataRequest { proposal_id })
1097                    .await?
1098                    .into_inner();
1099
1100                let mut start_rate_data = BTreeMap::new();
1101                while let Some(response) = rate_data_stream.message().await? {
1102                    let rate_data: RateData = response
1103                        .rate_data
1104                        .ok_or_else(|| {
1105                            anyhow::anyhow!("proposal rate data stream response missing rate data")
1106                        })?
1107                        .try_into()
1108                        .context("invalid rate data")?;
1109                    start_rate_data.insert(rate_data.identity_key.clone(), rate_data);
1110                }
1111
1112                let plan = Planner::new(OsRng)
1113                    .set_gas_prices(gas_prices)
1114                    .set_fee_tier((*fee_tier).into())
1115                    .delegator_vote(
1116                        app.view(),
1117                        AddressIndex::new(*source),
1118                        proposal_id,
1119                        vote,
1120                        start_block_height,
1121                        start_position,
1122                        start_rate_data,
1123                    )
1124                    .await?
1125                    .plan(
1126                        app.view
1127                            .as_mut()
1128                            .context("view service must be initialized")?,
1129                        AddressIndex::new(*source),
1130                    )
1131                    .await?;
1132
1133                app.build_and_submit_transaction(plan).await?;
1134            }
1135            TxCmd::Position(PositionCmd::Order(order)) => {
1136                let asset_cache = app.view().assets().await?;
1137
1138                tracing::info!(?order);
1139                let source = AddressIndex::new(order.source());
1140                let positions = order.as_position(&asset_cache, OsRng)?;
1141                tracing::info!(?positions);
1142                for position in &positions {
1143                    println!("Position id: {}", position.id());
1144                }
1145
1146                let mut planner = Planner::new(OsRng);
1147                planner
1148                    .set_gas_prices(gas_prices)
1149                    .set_fee_tier(order.fee_tier().into());
1150
1151                for position in positions {
1152                    planner.position_open(position);
1153                }
1154
1155                let plan = planner
1156                    .plan(
1157                        app.view
1158                            .as_mut()
1159                            .context("view service must be initialized")?,
1160                        source,
1161                    )
1162                    .await?;
1163
1164                app.build_and_submit_transaction(plan).await?;
1165            }
1166            TxCmd::Withdraw {
1167                to,
1168                value,
1169                timeout_height,
1170                timeout_timestamp,
1171                channel,
1172                source,
1173                memo,
1174                fee_tier,
1175                use_transparent_address,
1176            } => {
1177                let destination_chain_address = to;
1178
1179                let ephemeral_return_address = if *use_transparent_address {
1180                    let ivk = app.config.full_viewing_key.incoming();
1181
1182                    ivk.transparent_address()
1183                        .parse::<Address>()
1184                        .expect("we round-trip from a valid transparent address")
1185                } else {
1186                    app.config
1187                        .full_viewing_key
1188                        .ephemeral_address(OsRng, AddressIndex::from(*source))
1189                        .0
1190                };
1191
1192                let timeout_height = match timeout_height {
1193                    Some(h) => h.clone(),
1194                    None => {
1195                        // look up the height for the counterparty and add 2 days of block time
1196                        // (assuming 10 seconds per block) to it
1197
1198                        // look up the client state from the channel by looking up channel id -> connection id -> client state
1199                        let mut ibc_channel_client =
1200                            IbcChannelQueryClient::new(app.pd_channel().await?);
1201
1202                        let req = QueryChannelRequest {
1203                            port_id: PortId::transfer().to_string(),
1204                            channel_id: format!("channel-{}", channel),
1205                        };
1206
1207                        let channel = ibc_channel_client
1208                            .channel(req)
1209                            .await?
1210                            .into_inner()
1211                            .channel
1212                            .ok_or_else(|| anyhow::anyhow!("channel not found"))?;
1213
1214                        let connection_id = channel.connection_hops[0].clone();
1215
1216                        let mut ibc_connection_client =
1217                            IbcConnectionQueryClient::new(app.pd_channel().await?);
1218
1219                        let req = QueryConnectionRequest {
1220                            connection_id: connection_id.clone(),
1221                        };
1222                        let connection = ibc_connection_client
1223                            .connection(req)
1224                            .await?
1225                            .into_inner()
1226                            .connection
1227                            .ok_or_else(|| anyhow::anyhow!("connection not found"))?;
1228
1229                        let mut ibc_client_client =
1230                            IbcClientQueryClient::new(app.pd_channel().await?);
1231                        let req = QueryClientStateRequest {
1232                            client_id: connection.client_id,
1233                        };
1234                        let client_state = ibc_client_client
1235                            .client_state(req)
1236                            .await?
1237                            .into_inner()
1238                            .client_state
1239                            .ok_or_else(|| anyhow::anyhow!("client state not found"))?;
1240
1241                        let tm_client_state = TendermintClientState::try_from(client_state)?;
1242
1243                        let last_update_height = tm_client_state.latest_height;
1244
1245                        // 10 seconds per block, 2 days
1246                        let timeout_n_blocks = ((24 * 60 * 60) / 10) * 2;
1247
1248                        IbcHeight {
1249                            revision_number: last_update_height.revision_number,
1250                            revision_height: last_update_height.revision_height + timeout_n_blocks,
1251                        }
1252                    }
1253                };
1254
1255                // get the current time on the local machine
1256                let current_time_ns = SystemTime::now()
1257                    .duration_since(UNIX_EPOCH)
1258                    .expect("Time went backwards")
1259                    .as_nanos() as u64;
1260
1261                let mut timeout_timestamp = *timeout_timestamp;
1262                if timeout_timestamp == 0u64 {
1263                    // add 2 days to current time
1264                    timeout_timestamp = current_time_ns + 1.728e14 as u64;
1265                }
1266
1267                // round to the nearest 10 minutes
1268                timeout_timestamp += 600_000_000_000 - (timeout_timestamp % 600_000_000_000);
1269
1270                fn parse_denom_and_amount(value_str: &str) -> anyhow::Result<(Amount, Metadata)> {
1271                    let denom_re = Regex::new(r"^([0-9.]+)(.+)$").context("denom regex invalid")?;
1272                    if let Some(captures) = denom_re.captures(value_str) {
1273                        let numeric_str = captures.get(1).expect("matched regex").as_str();
1274                        let denom_str = captures.get(2).expect("matched regex").as_str();
1275
1276                        let display_denom = asset::REGISTRY.parse_unit(denom_str);
1277                        let amount = display_denom.parse_value(numeric_str)?;
1278                        let denom = display_denom.base();
1279
1280                        Ok((amount, denom))
1281                    } else {
1282                        Err(anyhow::anyhow!("could not parse value"))
1283                    }
1284                }
1285
1286                let (amount, denom) = parse_denom_and_amount(value)?;
1287
1288                let withdrawal = Ics20Withdrawal {
1289                    destination_chain_address: destination_chain_address.to_string(),
1290                    denom,
1291                    amount,
1292                    timeout_height,
1293                    timeout_time: timeout_timestamp,
1294                    return_address: ephemeral_return_address,
1295                    // TODO: impl From<u64> for ChannelId
1296                    source_channel: ChannelId::from_str(format!("channel-{}", channel).as_ref())?,
1297                    use_compat_address: false,
1298                    ics20_memo: memo.clone().unwrap_or_default(),
1299                    use_transparent_address: *use_transparent_address,
1300                };
1301
1302                let plan = Planner::new(OsRng)
1303                    .set_gas_prices(gas_prices)
1304                    .set_fee_tier((*fee_tier).into())
1305                    .ics20_withdrawal(withdrawal)
1306                    .plan(
1307                        app.view
1308                            .as_mut()
1309                            .context("view service must be initialized")?,
1310                        AddressIndex::new(*source),
1311                    )
1312                    .await?;
1313                app.build_and_submit_transaction(plan).await?;
1314            }
1315            TxCmd::Position(PositionCmd::Close {
1316                position_ids,
1317                source,
1318                fee_tier,
1319            }) => {
1320                let mut planner = Planner::new(OsRng);
1321                planner
1322                    .set_gas_prices(gas_prices)
1323                    .set_fee_tier((*fee_tier).into());
1324
1325                position_ids.iter().for_each(|position_id| {
1326                    planner.position_close(*position_id);
1327                });
1328
1329                let plan = planner
1330                    .plan(
1331                        app.view
1332                            .as_mut()
1333                            .context("view service must be initialized")?,
1334                        AddressIndex::new(*source),
1335                    )
1336                    .await?;
1337
1338                app.build_and_submit_transaction(plan).await?;
1339            }
1340            TxCmd::Position(PositionCmd::CloseAll {
1341                source,
1342                trading_pair,
1343                fee_tier,
1344            }) => {
1345                let view: &mut dyn ViewClient = app
1346                    .view
1347                    .as_mut()
1348                    .context("view service must be initialized")?;
1349
1350                let owned_position_ids = view
1351                    .owned_position_ids(Some(position::State::Opened), *trading_pair, None)
1352                    .await?;
1353
1354                if owned_position_ids.is_empty() {
1355                    println!("No open positions are available to close.");
1356                    return Ok(());
1357                }
1358
1359                println!(
1360                    "{} total open positions, closing in {} batches of {}",
1361                    owned_position_ids.len(),
1362                    owned_position_ids.len() / POSITION_CHUNK_SIZE + 1,
1363                    POSITION_CHUNK_SIZE
1364                );
1365
1366                let mut planner = Planner::new(OsRng);
1367
1368                // Close 5 positions in a single transaction to avoid planner failures.
1369                for positions_to_close_now in owned_position_ids.chunks(POSITION_CHUNK_SIZE) {
1370                    planner
1371                        .set_gas_prices(gas_prices)
1372                        .set_fee_tier((*fee_tier).into());
1373
1374                    for position_id in positions_to_close_now {
1375                        // Close the position
1376                        planner.position_close(*position_id);
1377                    }
1378
1379                    let final_plan = planner
1380                        .plan(
1381                            app.view
1382                                .as_mut()
1383                                .context("view service must be initialized")?,
1384                            AddressIndex::new(*source),
1385                        )
1386                        .await?;
1387                    app.build_and_submit_transaction(final_plan).await?;
1388                }
1389            }
1390            TxCmd::Position(PositionCmd::WithdrawAll {
1391                source,
1392                trading_pair,
1393                fee_tier,
1394            }) => {
1395                let view: &mut dyn ViewClient = app
1396                    .view
1397                    .as_mut()
1398                    .context("view service must be initialized")?;
1399
1400                let owned_position_ids = view
1401                    .owned_position_ids(Some(position::State::Closed), *trading_pair, None)
1402                    .await?;
1403
1404                if owned_position_ids.is_empty() {
1405                    println!("No closed positions are available to withdraw.");
1406                    return Ok(());
1407                }
1408
1409                println!(
1410                    "{} total closed positions, withdrawing in {} batches of {}",
1411                    owned_position_ids.len(),
1412                    owned_position_ids.len() / POSITION_CHUNK_SIZE + 1,
1413                    POSITION_CHUNK_SIZE,
1414                );
1415
1416                let mut client = DexQueryServiceClient::new(app.pd_channel().await?);
1417
1418                let mut planner = Planner::new(OsRng);
1419
1420                // Withdraw 5 positions in a single transaction to avoid planner failures.
1421                for positions_to_withdraw_now in owned_position_ids.chunks(POSITION_CHUNK_SIZE) {
1422                    planner
1423                        .set_gas_prices(gas_prices)
1424                        .set_fee_tier((*fee_tier).into());
1425
1426                    for position_id in positions_to_withdraw_now {
1427                        // Withdraw the position
1428
1429                        // Fetch the information regarding the position from the view service.
1430                        let position = client
1431                            .liquidity_position_by_id(LiquidityPositionByIdRequest {
1432                                position_id: Some((*position_id).into()),
1433                            })
1434                            .await?
1435                            .into_inner();
1436
1437                        let reserves = position
1438                            .data
1439                            .clone()
1440                            .expect("missing position metadata")
1441                            .reserves
1442                            .expect("missing position reserves");
1443                        let pair = position
1444                            .data
1445                            .expect("missing position")
1446                            .phi
1447                            .expect("missing position trading function")
1448                            .pair
1449                            .expect("missing trading function pair");
1450                        planner.position_withdraw(
1451                            *position_id,
1452                            reserves.try_into().expect("invalid reserves"),
1453                            pair.try_into().expect("invalid pair"),
1454                            0,
1455                        );
1456                    }
1457
1458                    let final_plan = planner
1459                        .plan(
1460                            app.view
1461                                .as_mut()
1462                                .context("view service must be initialized")?,
1463                            AddressIndex::new(*source),
1464                        )
1465                        .await?;
1466                    app.build_and_submit_transaction(final_plan).await?;
1467                }
1468            }
1469            TxCmd::Position(PositionCmd::Withdraw {
1470                source,
1471                position_ids,
1472                fee_tier,
1473            }) => {
1474                let mut client = DexQueryServiceClient::new(app.pd_channel().await?);
1475
1476                let mut planner = Planner::new(OsRng);
1477                planner
1478                    .set_gas_prices(gas_prices)
1479                    .set_fee_tier((*fee_tier).into());
1480
1481                for position_id in position_ids {
1482                    // Fetch the information regarding the position from the view service.
1483                    let response = client
1484                        .liquidity_position_by_id(LiquidityPositionByIdRequest {
1485                            position_id: Some(PositionId::from(*position_id)),
1486                        })
1487                        .await?
1488                        .into_inner();
1489
1490                    let position: Position = response
1491                        .data
1492                        .expect("missing position")
1493                        .try_into()
1494                        .expect("invalid position state");
1495
1496                    let reserves = position.reserves;
1497                    let pair = position.phi.pair;
1498                    let next_seq = match position.state {
1499                        State::Withdrawn { sequence } => sequence + 1,
1500                        State::Closed => 0,
1501                        _ => {
1502                            anyhow::bail!("position {} is not in a withdrawable state", position_id)
1503                        }
1504                    };
1505                    planner.position_withdraw(
1506                        *position_id,
1507                        reserves.try_into()?,
1508                        pair.try_into()?,
1509                        next_seq,
1510                    );
1511                }
1512
1513                let plan = planner
1514                    .plan(
1515                        app.view
1516                            .as_mut()
1517                            .context("view service must be initialized")?,
1518                        AddressIndex::new(*source),
1519                    )
1520                    .await?;
1521
1522                app.build_and_submit_transaction(plan).await?;
1523            }
1524            TxCmd::Position(PositionCmd::RewardClaim {}) => {
1525                unimplemented!("deprecated, remove this")
1526            }
1527            TxCmd::Position(PositionCmd::Replicate(replicate_cmd)) => {
1528                replicate_cmd.exec(app).await?;
1529            }
1530            TxCmd::Auction(AuctionCmd::Dutch(auction_cmd)) => {
1531                auction_cmd.exec(app).await?;
1532            }
1533            TxCmd::Broadcast { transaction } => {
1534                let transaction: Transaction = serde_json::from_slice(&fs::read(transaction)?)?;
1535                app.submit_transaction(transaction).await?;
1536            }
1537            TxCmd::RegisterForwardingAccount {
1538                noble_node,
1539                channel,
1540                address_or_index,
1541                ephemeral,
1542            } => {
1543                let index: Result<u32, _> = address_or_index.parse();
1544                let fvk = app.config.full_viewing_key.clone();
1545
1546                let address = if let Ok(index) = index {
1547                    // address index provided
1548                    let (address, _dtk) = match ephemeral {
1549                        false => fvk.incoming().payment_address(index.into()),
1550                        true => fvk.incoming().ephemeral_address(OsRng, index.into()),
1551                    };
1552
1553                    address
1554                } else {
1555                    // address or nothing provided
1556                    let address: Address = address_or_index
1557                        .parse()
1558                        .map_err(|_| anyhow::anyhow!("Provided address is invalid."))?;
1559
1560                    address
1561                };
1562
1563                let noble_address = address.noble_forwarding_address(channel);
1564
1565                println!(
1566                    "registering Noble forwarding account with address {} to forward to Penumbra address {}...",
1567                    noble_address, address
1568                );
1569
1570                let mut noble_client = CosmosServiceClient::new(
1571                    Channel::from_shared(noble_node.to_string())?
1572                        .tls_config(ClientTlsConfig::new().with_webpki_roots())?
1573                        .connect()
1574                        .await?,
1575                );
1576
1577                let tx = CosmosTx {
1578                    body: Some(CosmosTxBody {
1579                        messages: vec![pbjson_types::Any {
1580                            type_url: MsgRegisterAccount::type_url(),
1581                            value: MsgRegisterAccount {
1582                                signer: noble_address.to_string(),
1583                                recipient: address.to_string(),
1584                                channel: channel.to_string(),
1585                            }
1586                            .encode_to_vec()
1587                            .into(),
1588                        }],
1589                        memo: "".to_string(),
1590                        timeout_height: 0,
1591                        extension_options: vec![],
1592                        non_critical_extension_options: vec![],
1593                    }),
1594                    auth_info: Some(CosmosAuthInfo {
1595                        signer_infos: vec![CosmosSignerInfo {
1596                            public_key: Some(pbjson_types::Any {
1597                                type_url: ForwardingPubKey::type_url(),
1598                                value: ForwardingPubKey {
1599                                    key: noble_address.bytes(),
1600                                }
1601                                .encode_to_vec()
1602                                .into(),
1603                            }),
1604                            mode_info: Some(ModeInfo {
1605                                // SIGN_MODE_DIRECT
1606                                sum: Some(Sum::Single(Single { mode: 1 })),
1607                            }),
1608                            sequence: 0,
1609                        }],
1610                        fee: Some(CosmosFee {
1611                            amount: vec![],
1612                            gas_limit: 200000u64,
1613                            payer: "".to_string(),
1614                            granter: "".to_string(),
1615                        }),
1616                        tip: None,
1617                    }),
1618                    signatures: vec![vec![]],
1619                };
1620                let r = noble_client
1621                    .broadcast_tx(CosmosBroadcastTxRequest {
1622                        tx_bytes: tx.encode_to_vec().into(),
1623                        // sync
1624                        mode: 2,
1625                    })
1626                    .await?;
1627
1628                // let r = noble_client
1629                //     .register_account(MsgRegisterAccount {
1630                //         signer: noble_address,
1631                //         recipient: address.to_string(),
1632                //         channel: channel.to_string(),
1633                //     })
1634                //     .await?;
1635
1636                println!("Noble response: {:?}", r);
1637            }
1638            TxCmd::LqtVote(cmd) => cmd.exec(app, gas_prices).await?,
1639        }
1640
1641        Ok(())
1642    }
1643}