1use std::{
2 collections::BTreeMap,
3 fmt::{self, Debug, Formatter},
4 mem,
5};
6
7use anyhow::{Context, Result};
8use penumbra_sdk_sct::epoch::Epoch;
9use rand::{CryptoRng, RngCore};
10use rand_core::OsRng;
11use tracing::instrument;
12
13use crate::{SpendableNoteRecord, ViewClient};
14use anyhow::anyhow;
15use penumbra_sdk_asset::{asset, Value};
16use penumbra_sdk_auction::auction::dutch::DutchAuctionDescription;
17use penumbra_sdk_auction::auction::dutch::{actions::ActionDutchAuctionWithdrawPlan, DutchAuction};
18use penumbra_sdk_auction::auction::{
19 dutch::actions::{ActionDutchAuctionEnd, ActionDutchAuctionSchedule},
20 AuctionId,
21};
22use penumbra_sdk_community_pool::CommunityPoolDeposit;
23use penumbra_sdk_dex::{
24 lp::action::{PositionClose, PositionOpen},
25 lp::plan::PositionWithdrawPlan,
26 lp::position::{self, Position},
27 lp::Reserves,
28 swap::SwapPlaintext,
29 swap::SwapPlan,
30 swap_claim::SwapClaimPlan,
31 TradingPair,
32};
33use penumbra_sdk_fee::{Fee, FeeTier, GasPrices};
34use penumbra_sdk_governance::{
35 proposal_state, DelegatorVotePlan, Proposal, ProposalDepositClaim, ProposalSubmit,
36 ProposalWithdraw, ValidatorVote, Vote,
37};
38use penumbra_sdk_ibc::IbcRelay;
39use penumbra_sdk_keys::{keys::AddressIndex, Address};
40use penumbra_sdk_num::Amount;
41use penumbra_sdk_proto::view::v1::{NotesForVotingRequest, NotesRequest};
42use penumbra_sdk_shielded_pool::{Ics20Withdrawal, Note, OutputPlan, SpendPlan};
43use penumbra_sdk_stake::{rate::RateData, validator, IdentityKey, UndelegateClaimPlan};
44use penumbra_sdk_tct as tct;
45use penumbra_sdk_transaction::{
46 memo::MemoPlaintext,
47 plan::{ActionPlan, MemoPlan, TransactionPlan},
48 ActionList, TransactionParameters,
49};
50
51pub struct Planner<R: RngCore + CryptoRng> {
54 rng: R,
55 action_list: ActionList,
56 fee_tier: FeeTier,
58 gas_prices: Option<GasPrices>,
60 transaction_parameters: TransactionParameters,
62 change_address: Option<Address>,
64 memo_text: Option<String>,
66 memo_return_address: Option<Address>,
68}
69
70impl<R: RngCore + CryptoRng> Debug for Planner<R> {
71 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
72 f.debug_struct("Planner")
73 .field("action_list", &self.action_list)
74 .field("fee_tier", &self.fee_tier)
75 .field("gas_prices", &self.gas_prices)
76 .field("transaction_parameters", &self.transaction_parameters)
77 .field("change_address", &self.change_address)
78 .field("memo_text", &self.memo_text)
79 .field("memo_return_address", &self.memo_return_address)
80 .finish()
81 }
82}
83
84impl<R: RngCore + CryptoRng> Planner<R> {
85 pub fn new(rng: R) -> Self {
87 Self {
88 rng,
89 action_list: Default::default(),
90 gas_prices: Default::default(),
91 fee_tier: Default::default(),
92 transaction_parameters: Default::default(),
93 change_address: None,
94 memo_text: None,
95 memo_return_address: None,
96 }
97 }
98
99 pub fn action<A: Into<ActionPlan>>(&mut self, action: A) -> &mut Self {
101 self.action_list.push(action);
102 self
103 }
104
105 #[instrument(skip(self))]
107 pub fn set_gas_prices(&mut self, gas_prices: GasPrices) -> &mut Self {
108 self.gas_prices = Some(gas_prices);
109 self
110 }
111
112 #[instrument(skip(self))]
114 pub fn set_fee_tier(&mut self, fee_tier: FeeTier) -> &mut Self {
115 self.fee_tier = fee_tier;
116 self
117 }
118
119 #[instrument(skip(self))]
121 pub fn expiry_height(&mut self, expiry_height: u64) -> &mut Self {
122 self.transaction_parameters.expiry_height = expiry_height;
123 self
124 }
125
126 #[instrument(skip(self))]
130 pub fn memo(&mut self, text: String) -> &mut Self {
131 self.memo_text = Some(text);
132 self
133 }
134
135 #[instrument(skip(self))]
141 pub fn memo_return_address(&mut self, address: Address) -> &mut Self {
142 self.memo_return_address = Some(address);
143 self
144 }
145
146 #[instrument(skip(self))]
152 pub fn change_address(&mut self, address: Address) -> &mut Self {
153 self.change_address = Some(address);
154 self
155 }
156
157 #[instrument(skip(self))]
159 pub fn spend(&mut self, note: Note, position: tct::Position) -> &mut Self {
160 self.action_list
161 .push(SpendPlan::new(&mut self.rng, note, position));
162 self
163 }
164
165 #[instrument(skip(self))]
169 pub fn output(&mut self, value: Value, address: Address) -> &mut Self {
170 self.action_list
171 .push(OutputPlan::new(&mut self.rng, value, address));
172 self
173 }
174
175 #[instrument(skip(self))]
177 pub fn position_open(&mut self, position: Position) -> &mut Self {
178 self.action_list.push(PositionOpen { position });
179 self
180 }
181
182 #[instrument(skip(self))]
184 pub fn position_close(&mut self, position_id: position::Id) -> &mut Self {
185 self.action_list.push(PositionClose { position_id });
186 self
187 }
188
189 #[instrument(skip(self))]
193 pub fn position_withdraw(
194 &mut self,
195 position_id: position::Id,
196 reserves: Reserves,
197 pair: TradingPair,
198 ) -> &mut Self {
199 self.action_list.push(PositionWithdrawPlan {
200 reserves,
201 position_id,
202 pair,
203 sequence: 0,
204 rewards: Vec::new(),
205 });
206 self
207 }
208
209 #[instrument(skip(self))]
211 pub fn dutch_auction_schedule(&mut self, description: DutchAuctionDescription) -> &mut Self {
212 self.action_list
213 .push(ActionDutchAuctionSchedule { description });
214 self
215 }
216
217 #[instrument(skip(self))]
219 pub fn dutch_auction_end(&mut self, auction_id: AuctionId) -> &mut Self {
220 self.action_list.push(ActionDutchAuctionEnd { auction_id });
221 self
222 }
223
224 #[instrument(skip(self))]
229 pub fn dutch_auction_withdraw(&mut self, auction: &DutchAuction) -> &mut Self {
230 let auction_id = auction.description.id();
231 if auction.state.sequence == 0 {
233 self.dutch_auction_end(auction_id);
234 }
235
236 let reserves_input = Value {
237 amount: auction.state.input_reserves,
238 asset_id: auction.description.input.asset_id,
239 };
240 let reserves_output = Value {
241 amount: auction.state.output_reserves,
242 asset_id: auction.description.output_id,
243 };
244
245 let plan = ActionDutchAuctionWithdrawPlan {
246 auction_id,
247 seq: 2, reserves_input,
249 reserves_output,
250 };
251
252 self.action_list.push(plan);
253 self
254 }
255
256 #[instrument(skip(self))]
258 pub fn swap(
259 &mut self,
260 input_value: Value,
261 into_asset: asset::Id,
262 swap_claim_fee: Fee,
263 claim_address: Address,
264 ) -> Result<&mut Self> {
265 let trading_pair = TradingPair::new(input_value.asset_id, into_asset);
268
269 let (delta_1, delta_2) = if trading_pair.asset_1() == input_value.asset_id {
274 (input_value.amount, 0u64.into())
275 } else {
276 (0u64.into(), input_value.amount)
277 };
278
279 if delta_1 == Amount::zero() && delta_2 == Amount::zero() {
281 anyhow::bail!("No input value for swap");
282 }
283
284 let swap_plaintext = SwapPlaintext::new(
286 &mut self.rng,
287 trading_pair,
288 delta_1,
289 delta_2,
290 swap_claim_fee,
291 claim_address,
292 );
293
294 let swap = SwapPlan::new(&mut self.rng, swap_plaintext);
295 self.action_list.push(swap);
296
297 Ok(self)
298 }
299
300 #[instrument(skip(self))]
302 pub fn swap_claim(&mut self, plan: SwapClaimPlan) -> &mut Self {
303 self.action_list.push(plan);
304 self
305 }
306
307 #[instrument(skip(self))]
309 pub fn delegate(
310 &mut self,
311 epoch: Epoch,
312 unbonded_amount: Amount,
313 rate_data: RateData,
314 ) -> &mut Self {
315 let delegation = rate_data.build_delegate(epoch, unbonded_amount);
316 self.action_list.push(delegation);
317 self
318 }
319
320 #[instrument(skip(self))]
322 pub fn undelegate(
323 &mut self,
324 epoch: Epoch,
325 delegation_amount: Amount,
326 rate_data: RateData,
327 ) -> &mut Self {
328 let undelegation = rate_data.build_undelegate(epoch, delegation_amount);
329 self.action_list.push(undelegation);
330 self
331 }
332
333 #[instrument(skip(self))]
335 pub fn undelegate_claim(&mut self, claim_plan: UndelegateClaimPlan) -> &mut Self {
336 self.action_list.push(claim_plan);
337 self
338 }
339
340 #[instrument(skip(self))]
342 pub fn validator_definition(&mut self, new_validator: validator::Definition) -> &mut Self {
343 self.action_list.push(new_validator);
344 self
345 }
346
347 #[instrument(skip(self))]
349 pub fn proposal_submit(&mut self, proposal: Proposal, deposit_amount: Amount) -> &mut Self {
350 self.action_list.push(ProposalSubmit {
351 proposal,
352 deposit_amount,
353 });
354 self
355 }
356
357 #[instrument(skip(self))]
359 pub fn proposal_withdraw(&mut self, proposal: u64, reason: String) -> &mut Self {
360 self.action_list.push(ProposalWithdraw { proposal, reason });
361 self
362 }
363
364 #[instrument(skip(self))]
366 pub fn proposal_deposit_claim(
367 &mut self,
368 proposal: u64,
369 deposit_amount: Amount,
370 outcome: proposal_state::Outcome<()>,
371 ) -> &mut Self {
372 self.action_list.push(ProposalDepositClaim {
373 proposal,
374 deposit_amount,
375 outcome,
376 });
377 self
378 }
379
380 #[instrument(skip(self))]
382 pub fn community_pool_deposit(&mut self, value: Value) -> &mut Self {
383 self.action_list.push(CommunityPoolDeposit { value });
384 self
385 }
386
387 #[instrument(skip(self))]
389 pub fn validator_vote(&mut self, vote: ValidatorVote) -> &mut Self {
390 self.action_list.push(vote);
391 self
392 }
393
394 #[instrument(skip(self))]
396 pub fn ics20_withdrawal(&mut self, withdrawal: Ics20Withdrawal) -> &mut Self {
397 self.action_list.push(withdrawal);
398 self
399 }
400
401 #[instrument(skip(self))]
403 pub fn ibc_action(&mut self, ibc_action: IbcRelay) -> &mut Self {
404 self.action_list.push(ibc_action);
405 self
406 }
407
408 #[instrument(skip_all)]
410 pub async fn delegator_vote<V: ViewClient>(
411 &mut self,
415 view: &mut V,
416 source: AddressIndex,
417 proposal: u64,
418 vote: Vote,
419 start_block_height: u64,
420 start_position: tct::Position,
421 start_rate_data: BTreeMap<IdentityKey, RateData>,
422 ) -> Result<&mut Self, anyhow::Error> {
423 let voting_notes = view
424 .notes_for_voting(NotesForVotingRequest {
425 votable_at_height: start_block_height,
426 address_index: Some(source.into()),
427 })
428 .await?;
429
430 anyhow::ensure!(
431 !voting_notes.is_empty(),
432 "no notes were found for voting on proposal {}",
433 proposal
434 );
435
436 for (record, ik) in &voting_notes {
438 let Some(validator_start_rate_data) = start_rate_data.get(&ik) else {
439 tracing::debug!("missing rate data for votable note delegated to {}", ik);
440 continue;
441 };
442
443 let voting_power_at_vote_start =
444 validator_start_rate_data.unbonded_amount(record.note.amount());
445
446 let plan = DelegatorVotePlan::new(
448 &mut self.rng,
449 proposal,
450 start_position,
451 vote,
452 record.note.clone(),
453 record.position,
454 voting_power_at_vote_start,
455 );
456 self.delegator_vote_precise(plan);
457 }
458
459 Ok(self)
471 }
472
473 #[instrument(skip(self, plan))]
475 pub fn delegator_vote_precise(&mut self, plan: DelegatorVotePlan) -> &mut Self {
476 self.action_list.push(plan);
477 self
478 }
479
480 pub fn prioritize_and_filter_spendable_notes(
500 &mut self,
501 records: Vec<SpendableNoteRecord>,
502 ) -> Vec<SpendableNoteRecord> {
503 let mut filtered = records
504 .into_iter()
505 .filter(|record| record.note.amount() > Amount::zero())
506 .collect::<Vec<_>>();
507 filtered.sort_by(|a, b| {
508 match (
510 a.address_index.is_ephemeral(),
511 b.address_index.is_ephemeral(),
512 ) {
513 (true, false) => std::cmp::Ordering::Less,
514 (false, true) => std::cmp::Ordering::Greater,
515 _ => b.note.amount().cmp(&a.note.amount()),
517 }
518 });
519 filtered
520 }
521
522 pub async fn plan<V: ViewClient>(
525 &mut self,
526 view: &mut V,
527 mut source: AddressIndex,
528 ) -> anyhow::Result<TransactionPlan> {
529 source.randomizer = [0u8; 12];
533
534 let change_address = if let Some(ref address) = self.change_address {
536 address.clone()
537 } else {
538 view.address_by_index(source).await?.clone()
539 };
540
541 self.action_list.refresh_fee_and_change(
546 &mut self.rng,
547 &self
548 .gas_prices
549 .context("planner instances must call set_gas_prices prior to planning")?,
550 &self.fee_tier,
551 &change_address,
552 );
553
554 let mut notes_by_asset_id = BTreeMap::new();
562 for required in self.action_list.balance_with_fee().required() {
563 let records: Vec<SpendableNoteRecord> = view
565 .notes(NotesRequest {
566 include_spent: false,
567 asset_id: Some(required.asset_id.into()),
568 address_index: Some(source.into()),
569 amount_to_spend: None,
570 })
571 .await?;
572 notes_by_asset_id.insert(
573 required.asset_id,
574 self.prioritize_and_filter_spendable_notes(records),
575 );
576 }
577
578 let mut iterations = 0usize;
579 let asset_cache = view.assets().await?;
580
581 while let Some(required) = self.action_list.balance_with_fee().required().next() {
583 let note = notes_by_asset_id
585 .get_mut(&required.asset_id)
586 .expect("we already made a notes request for each required asset")
587 .pop()
588 .ok_or_else(|| {
589 anyhow!(
590 "ran out of notes to spend while planning transaction, need {}",
591 required.format(&asset_cache)
592 )
593 })?;
594
595 self.action_list
597 .push(SpendPlan::new(&mut OsRng, note.note, note.position));
598
599 self.action_list.refresh_fee_and_change(
601 &mut self.rng,
602 &self
603 .gas_prices
604 .context("planner instances must call set_gas_prices prior to planning")?,
605 &self.fee_tier,
606 &change_address,
607 );
608
609 iterations = iterations + 1;
610 if iterations > 100 {
611 return Err(anyhow!("failed to plan transaction after 100 iterations"));
612 }
613 }
614
615 let memo_plan = if self.action_list.requires_memo() {
618 let return_address = if let Some(ref address) = self.memo_return_address {
619 anyhow::ensure!(
622 view.index_by_address(address.clone()).await?.is_some(),
623 "return address for memo is not controlled by the user",
624 );
625 address.clone()
626 } else {
627 view.address_by_index(source).await?.clone()
628 };
629
630 Some(MemoPlan::new(
631 &mut self.rng,
632 MemoPlaintext::new(return_address, self.memo_text.take().unwrap_or_default())
633 .context("could not create memo plaintext")?,
634 ))
635 } else {
636 None
637 };
638
639 let app_params = view.app_params().await?;
641 let chain_id = app_params.chain_id.clone();
642 self.transaction_parameters.chain_id = chain_id.clone();
643
644 let fmd_params = view.fmd_parameters().await?;
647
648 let plan = mem::take(&mut self.action_list).into_plan(
649 &mut self.rng,
650 &fmd_params,
651 self.transaction_parameters.clone(),
652 memo_plan,
653 )?;
654
655 self.action_list = Default::default();
661 self.gas_prices = Default::default();
662 self.fee_tier = Default::default();
663 self.transaction_parameters = Default::default();
664 self.change_address = None;
665 self.memo_text = None;
666 self.memo_return_address = None;
667
668 Ok(plan)
669 }
670}