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