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