1use std::{
2 collections::BTreeMap,
3 fmt::{self, Debug, Formatter},
4 mem,
5};
6
7use anyhow::{Context, Result};
8use penumbra_sdk_funding::liquidity_tournament::ActionLiquidityTournamentVotePlan;
9use penumbra_sdk_sct::epoch::Epoch;
10use rand::{CryptoRng, RngCore};
11use rand_core::OsRng;
12use tracing::instrument;
13
14use crate::{SpendableNoteRecord, ViewClient};
15use anyhow::anyhow;
16use penumbra_sdk_asset::{
17 asset::{self, Denom},
18 Value,
19};
20use penumbra_sdk_auction::auction::dutch::DutchAuctionDescription;
21use penumbra_sdk_auction::auction::dutch::{actions::ActionDutchAuctionWithdrawPlan, DutchAuction};
22use penumbra_sdk_auction::auction::{
23 dutch::actions::{ActionDutchAuctionEnd, ActionDutchAuctionSchedule},
24 AuctionId,
25};
26use penumbra_sdk_community_pool::CommunityPoolDeposit;
27use penumbra_sdk_dex::{
28 lp::{
29 action::PositionClose,
30 plan::{PositionOpenPlan, PositionWithdrawPlan},
31 position::{self, Position},
32 PositionMetadata, Reserves,
33 },
34 swap::{SwapPlaintext, SwapPlan},
35 swap_claim::SwapClaimPlan,
36 TradingPair,
37};
38use penumbra_sdk_fee::{Fee, FeeTier, GasPrices};
39use penumbra_sdk_governance::{
40 proposal_state, DelegatorVotePlan, Proposal, ProposalDepositClaim, ProposalSubmit,
41 ProposalWithdraw, ValidatorVote, Vote,
42};
43use penumbra_sdk_ibc::IbcRelay;
44use penumbra_sdk_keys::{keys::AddressIndex, Address};
45use penumbra_sdk_num::Amount;
46use penumbra_sdk_proto::view::v1::{NotesForVotingRequest, NotesRequest};
47use penumbra_sdk_shielded_pool::{Ics20Withdrawal, Note, OutputPlan, SpendPlan};
48use penumbra_sdk_stake::{rate::RateData, validator, IdentityKey, UndelegateClaimPlan};
49use penumbra_sdk_tct as tct;
50use penumbra_sdk_transaction::{
51 memo::MemoPlaintext,
52 plan::{ActionPlan, MemoPlan, TransactionPlan},
53 ActionList, TransactionParameters,
54};
55
56pub struct Planner<R: RngCore + CryptoRng> {
59 rng: R,
60 action_list: ActionList,
61 fee_tier: FeeTier,
63 gas_prices: Option<GasPrices>,
65 transaction_parameters: TransactionParameters,
67 change_address: Option<Address>,
69 memo_text: Option<String>,
71 memo_return_address: Option<Address>,
73}
74
75impl<R: RngCore + CryptoRng> Debug for Planner<R> {
76 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
77 f.debug_struct("Planner")
78 .field("action_list", &self.action_list)
79 .field("fee_tier", &self.fee_tier)
80 .field("gas_prices", &self.gas_prices)
81 .field("transaction_parameters", &self.transaction_parameters)
82 .field("change_address", &self.change_address)
83 .field("memo_text", &self.memo_text)
84 .field("memo_return_address", &self.memo_return_address)
85 .finish()
86 }
87}
88
89impl<R: RngCore + CryptoRng> Planner<R> {
90 pub fn new(rng: R) -> Self {
92 Self {
93 rng,
94 action_list: Default::default(),
95 gas_prices: Default::default(),
96 fee_tier: Default::default(),
97 transaction_parameters: Default::default(),
98 change_address: None,
99 memo_text: None,
100 memo_return_address: None,
101 }
102 }
103
104 pub fn action<A: Into<ActionPlan>>(&mut self, action: A) -> &mut Self {
106 self.action_list.push(action);
107 self
108 }
109
110 #[instrument(skip(self))]
112 pub fn set_gas_prices(&mut self, gas_prices: GasPrices) -> &mut Self {
113 self.gas_prices = Some(gas_prices);
114 self
115 }
116
117 #[instrument(skip(self))]
119 pub fn set_fee_tier(&mut self, fee_tier: FeeTier) -> &mut Self {
120 self.fee_tier = fee_tier;
121 self
122 }
123
124 #[instrument(skip(self))]
126 pub fn expiry_height(&mut self, expiry_height: u64) -> &mut Self {
127 self.transaction_parameters.expiry_height = expiry_height;
128 self
129 }
130
131 #[instrument(skip(self))]
135 pub fn memo(&mut self, text: String) -> &mut Self {
136 self.memo_text = Some(text);
137 self
138 }
139
140 #[instrument(skip(self))]
146 pub fn memo_return_address(&mut self, address: Address) -> &mut Self {
147 self.memo_return_address = Some(address);
148 self
149 }
150
151 #[instrument(skip(self))]
157 pub fn change_address(&mut self, address: Address) -> &mut Self {
158 self.change_address = Some(address);
159 self
160 }
161
162 #[instrument(skip(self))]
164 pub fn spend(&mut self, note: Note, position: tct::Position) -> &mut Self {
165 self.action_list
166 .push(SpendPlan::new(&mut self.rng, note, position));
167 self
168 }
169
170 #[instrument(skip(self))]
174 pub fn output(&mut self, value: Value, address: Address) -> &mut Self {
175 self.action_list
176 .push(OutputPlan::new(&mut self.rng, value, address));
177 self
178 }
179
180 #[instrument(skip(self))]
182 pub fn position_open(&mut self, position: Position) -> &mut Self {
183 self.action_list.push(PositionOpenPlan {
184 position,
185 metadata: Some(PositionMetadata::default()),
186 });
187 self
188 }
189
190 #[instrument(skip(self))]
192 pub fn position_open_with_metadata(
193 &mut self,
194 position: Position,
195 metadata: PositionMetadata,
196 ) -> &mut Self {
197 self.action_list.push(PositionOpenPlan {
198 position,
199 metadata: Some(metadata),
200 });
201 self
202 }
203
204 #[instrument(skip(self))]
206 pub fn position_close(&mut self, position_id: position::Id) -> &mut Self {
207 self.action_list.push(PositionClose { position_id });
208 self
209 }
210
211 #[instrument(skip(self))]
215 pub fn position_withdraw(
216 &mut self,
217 position_id: position::Id,
218 reserves: Reserves,
219 pair: TradingPair,
220 next_sequence: u64,
221 ) -> &mut Self {
222 self.action_list.push(PositionWithdrawPlan {
223 reserves,
224 position_id,
225 pair,
226 sequence: next_sequence,
227 rewards: Vec::new(),
228 });
229 self
230 }
231
232 #[instrument(skip(self))]
234 pub fn dutch_auction_schedule(&mut self, description: DutchAuctionDescription) -> &mut Self {
235 self.action_list
236 .push(ActionDutchAuctionSchedule { description });
237 self
238 }
239
240 #[instrument(skip(self))]
242 pub fn dutch_auction_end(&mut self, auction_id: AuctionId) -> &mut Self {
243 self.action_list.push(ActionDutchAuctionEnd { auction_id });
244 self
245 }
246
247 #[instrument(skip(self))]
252 pub fn dutch_auction_withdraw(&mut self, auction: &DutchAuction) -> &mut Self {
253 let auction_id = auction.description.id();
254 if auction.state.sequence == 0 {
256 self.dutch_auction_end(auction_id);
257 }
258
259 let reserves_input = Value {
260 amount: auction.state.input_reserves,
261 asset_id: auction.description.input.asset_id,
262 };
263 let reserves_output = Value {
264 amount: auction.state.output_reserves,
265 asset_id: auction.description.output_id,
266 };
267
268 let plan = ActionDutchAuctionWithdrawPlan {
269 auction_id,
270 seq: 2, reserves_input,
272 reserves_output,
273 };
274
275 self.action_list.push(plan);
276 self
277 }
278
279 #[instrument(skip(self))]
281 pub fn swap(
282 &mut self,
283 input_value: Value,
284 into_asset: asset::Id,
285 swap_claim_fee: Fee,
286 claim_address: Address,
287 ) -> Result<&mut Self> {
288 let trading_pair = TradingPair::new(input_value.asset_id, into_asset);
291
292 let (delta_1, delta_2) = if trading_pair.asset_1() == input_value.asset_id {
297 (input_value.amount, 0u64.into())
298 } else {
299 (0u64.into(), input_value.amount)
300 };
301
302 if delta_1 == Amount::zero() && delta_2 == Amount::zero() {
304 anyhow::bail!("No input value for swap");
305 }
306
307 let swap_plaintext = SwapPlaintext::new(
309 &mut self.rng,
310 trading_pair,
311 delta_1,
312 delta_2,
313 swap_claim_fee,
314 claim_address,
315 );
316
317 let swap = SwapPlan::new(&mut self.rng, swap_plaintext);
318 self.action_list.push(swap);
319
320 Ok(self)
321 }
322
323 #[instrument(skip(self))]
325 pub fn swap_claim(&mut self, plan: SwapClaimPlan) -> &mut Self {
326 self.action_list.push(plan);
327 self
328 }
329
330 #[instrument(skip(self))]
332 pub fn delegate(
333 &mut self,
334 epoch: Epoch,
335 unbonded_amount: Amount,
336 rate_data: RateData,
337 ) -> &mut Self {
338 let delegation = rate_data.build_delegate(epoch, unbonded_amount);
339 self.action_list.push(delegation);
340 self
341 }
342
343 #[instrument(skip(self))]
345 pub fn undelegate(
346 &mut self,
347 epoch: Epoch,
348 delegation_amount: Amount,
349 rate_data: RateData,
350 ) -> &mut Self {
351 let undelegation = rate_data.build_undelegate(epoch, delegation_amount);
352 self.action_list.push(undelegation);
353 self
354 }
355
356 #[instrument(skip(self))]
358 pub fn undelegate_claim(&mut self, claim_plan: UndelegateClaimPlan) -> &mut Self {
359 self.action_list.push(claim_plan);
360 self
361 }
362
363 #[instrument(skip(self))]
365 pub fn validator_definition(&mut self, new_validator: validator::Definition) -> &mut Self {
366 self.action_list.push(new_validator);
367 self
368 }
369
370 #[instrument(skip(self))]
372 pub fn proposal_submit(&mut self, proposal: Proposal, deposit_amount: Amount) -> &mut Self {
373 self.action_list.push(ProposalSubmit {
374 proposal,
375 deposit_amount,
376 });
377 self
378 }
379
380 #[instrument(skip(self))]
382 pub fn proposal_withdraw(&mut self, proposal: u64, reason: String) -> &mut Self {
383 self.action_list.push(ProposalWithdraw { proposal, reason });
384 self
385 }
386
387 #[instrument(skip(self))]
389 pub fn proposal_deposit_claim(
390 &mut self,
391 proposal: u64,
392 deposit_amount: Amount,
393 outcome: proposal_state::Outcome<()>,
394 ) -> &mut Self {
395 self.action_list.push(ProposalDepositClaim {
396 proposal,
397 deposit_amount,
398 outcome,
399 });
400 self
401 }
402
403 #[instrument(skip(self))]
405 pub fn community_pool_deposit(&mut self, value: Value) -> &mut Self {
406 self.action_list.push(CommunityPoolDeposit { value });
407 self
408 }
409
410 #[instrument(skip(self))]
412 pub fn validator_vote(&mut self, vote: ValidatorVote) -> &mut Self {
413 self.action_list.push(vote);
414 self
415 }
416
417 #[instrument(skip(self))]
419 pub fn ics20_withdrawal(&mut self, withdrawal: Ics20Withdrawal) -> &mut Self {
420 self.action_list.push(withdrawal);
421 self
422 }
423
424 #[instrument(skip(self))]
426 pub fn ibc_action(&mut self, ibc_action: IbcRelay) -> &mut Self {
427 self.action_list.push(ibc_action);
428 self
429 }
430
431 #[instrument(skip_all)]
433 pub async fn delegator_vote<V: ViewClient>(
434 &mut self,
438 view: &mut V,
439 source: AddressIndex,
440 proposal: u64,
441 vote: Vote,
442 start_block_height: u64,
443 start_position: tct::Position,
444 start_rate_data: BTreeMap<IdentityKey, RateData>,
445 ) -> Result<&mut Self, anyhow::Error> {
446 let voting_notes = view
447 .notes_for_voting(NotesForVotingRequest {
448 votable_at_height: start_block_height,
449 address_index: Some(source.into()),
450 })
451 .await?;
452
453 anyhow::ensure!(
454 !voting_notes.is_empty(),
455 "no notes were found for voting on proposal {}",
456 proposal
457 );
458
459 for (record, ik) in &voting_notes {
461 let Some(validator_start_rate_data) = start_rate_data.get(&ik) else {
462 tracing::debug!("missing rate data for votable note delegated to {}", ik);
463 continue;
464 };
465
466 let voting_power_at_vote_start =
467 validator_start_rate_data.unbonded_amount(record.note.amount());
468
469 let plan = DelegatorVotePlan::new(
471 &mut self.rng,
472 proposal,
473 start_position,
474 vote,
475 record.note.clone(),
476 record.position,
477 voting_power_at_vote_start,
478 );
479 self.delegator_vote_precise(plan);
480 }
481
482 Ok(self)
494 }
495
496 #[instrument(skip(self, plan))]
498 pub fn delegator_vote_precise(&mut self, plan: DelegatorVotePlan) -> &mut Self {
499 self.action_list.push(plan);
500 self
501 }
502
503 #[instrument(skip(self))]
504 pub fn lqt_vote(
505 &mut self,
506 epoch_index: u16,
507 incentivized: Denom,
508 rewards_recipient: Address,
509 notes: &[SpendableNoteRecord],
510 ) -> &mut Self {
511 let start_position = tct::Position::from((epoch_index, 0, 0));
512 for note in notes {
513 self.action_list
514 .push(ActionLiquidityTournamentVotePlan::new(
515 &mut self.rng,
516 incentivized.clone(),
517 rewards_recipient.clone(),
518 note.note.clone(),
519 note.position,
520 start_position,
521 ));
522 }
523 self
524 }
525
526 pub fn prioritize_and_filter_spendable_notes(
546 &mut self,
547 records: Vec<SpendableNoteRecord>,
548 ) -> Vec<SpendableNoteRecord> {
549 let mut filtered = records
550 .into_iter()
551 .filter(|record| record.note.amount() > Amount::zero())
552 .collect::<Vec<_>>();
553 filtered.sort_by(|a, b| {
554 match (
556 a.address_index.is_ephemeral(),
557 b.address_index.is_ephemeral(),
558 ) {
559 (true, false) => std::cmp::Ordering::Less,
560 (false, true) => std::cmp::Ordering::Greater,
561 _ => b.note.amount().cmp(&a.note.amount()),
563 }
564 });
565 filtered
566 }
567
568 pub async fn plan<V: ViewClient + ?Sized>(
571 &mut self,
572 view: &mut V,
573 mut source: AddressIndex,
574 ) -> anyhow::Result<TransactionPlan> {
575 source.randomizer = [0u8; 12];
579
580 let change_address = if let Some(ref address) = self.change_address {
582 address.clone()
583 } else {
584 view.address_by_index(source).await?.clone()
585 };
586
587 self.action_list.refresh_fee_and_change(
592 &mut self.rng,
593 &self
594 .gas_prices
595 .context("planner instances must call set_gas_prices prior to planning")?,
596 &self.fee_tier,
597 &change_address,
598 );
599
600 let mut notes_by_asset_id = BTreeMap::new();
608 for required in self.action_list.balance_with_fee().required() {
609 let records: Vec<SpendableNoteRecord> = view
611 .notes(NotesRequest {
612 include_spent: false,
613 asset_id: Some(required.asset_id.into()),
614 address_index: Some(source.into()),
615 amount_to_spend: None,
616 })
617 .await?;
618 notes_by_asset_id.insert(
619 required.asset_id,
620 self.prioritize_and_filter_spendable_notes(records),
621 );
622 }
623
624 let mut iterations = 0usize;
625 let asset_cache = view.assets().await?;
626
627 while let Some(required) = self.action_list.balance_with_fee().required().next() {
629 let note = notes_by_asset_id
631 .get_mut(&required.asset_id)
632 .expect("we already made a notes request for each required asset")
633 .pop()
634 .ok_or_else(|| {
635 anyhow!(
636 "ran out of notes to spend while planning transaction, need {}",
637 required.format(&asset_cache)
638 )
639 })?;
640
641 self.action_list
643 .push(SpendPlan::new(&mut OsRng, note.note, note.position));
644
645 self.action_list.refresh_fee_and_change(
647 &mut self.rng,
648 &self
649 .gas_prices
650 .context("planner instances must call set_gas_prices prior to planning")?,
651 &self.fee_tier,
652 &change_address,
653 );
654
655 iterations = iterations + 1;
656 if iterations > 100 {
657 return Err(anyhow!("failed to plan transaction after 100 iterations"));
658 }
659 }
660
661 let memo_plan = if self.action_list.requires_memo() {
664 let return_address = if let Some(ref address) = self.memo_return_address {
665 anyhow::ensure!(
668 view.index_by_address(address.clone()).await?.is_some(),
669 "return address for memo is not controlled by the user",
670 );
671 address.clone()
672 } else {
673 view.address_by_index(source).await?.clone()
674 };
675
676 Some(MemoPlan::new(
677 &mut self.rng,
678 MemoPlaintext::new(return_address, self.memo_text.take().unwrap_or_default())
679 .context("could not create memo plaintext")?,
680 ))
681 } else {
682 None
683 };
684
685 let app_params = view.app_params().await?;
687 let chain_id = app_params.chain_id.clone();
688 self.transaction_parameters.chain_id = chain_id.clone();
689
690 let fmd_params = view.fmd_parameters().await?;
693
694 let plan = mem::take(&mut self.action_list).into_plan(
695 &mut self.rng,
696 &fmd_params,
697 self.transaction_parameters.clone(),
698 memo_plan,
699 )?;
700
701 self.action_list = Default::default();
707 self.gas_prices = Default::default();
708 self.fee_tier = Default::default();
709 self.transaction_parameters = Default::default();
710 self.change_address = None;
711 self.memo_text = None;
712 self.memo_return_address = None;
713
714 Ok(plan)
715 }
716}