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