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