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