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