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