penumbra_sdk_view/
planner.rs

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
51/// A planner for a [`TransactionPlan`] that can fill in the required spends and change outputs upon
52/// finalization to make a transaction balance.
53pub struct Planner<R: RngCore + CryptoRng> {
54    rng: R,
55    action_list: ActionList,
56    /// The fee tier to apply to this transaction.
57    fee_tier: FeeTier,
58    /// The set of prices used for gas estimation.
59    gas_prices: Option<GasPrices>,
60    /// The transaction parameters to use for the transaction.
61    transaction_parameters: TransactionParameters,
62    /// A user-specified change address, if any.
63    change_address: Option<Address>,
64    /// A user-specified memo text, if any.
65    memo_text: Option<String>,
66    /// A user-specified memo return address, if any.
67    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    /// Create a new planner.
86    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    /// Add an arbitrary action to the planner.
100    pub fn action<A: Into<ActionPlan>>(&mut self, action: A) -> &mut Self {
101        self.action_list.push(action);
102        self
103    }
104
105    /// Set the current gas prices for fee prediction.
106    #[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    /// Set the fee tier.
113    #[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    /// Set the expiry height for the transaction.
120    #[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    /// Set a human-readable memo text for the transaction.
127    ///
128    /// Errors if the memo is too long.
129    #[instrument(skip(self))]
130    pub fn memo(&mut self, text: String) -> &mut Self {
131        self.memo_text = Some(text);
132        self
133    }
134
135    /// Customize the return address for the memo.
136    ///
137    /// If unset, this will default to the address for the source account.  This
138    /// must be an address controlled by the user, as the expectation is that
139    /// the recipient can use the address to transact with the sender.
140    #[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    /// Set the change address for the transaction.
147    ///
148    /// If unset, this will default to the address for the source account.
149    ///
150    /// This can be a foreign address, allowing "send max" functionality.
151    #[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    /// Spend a specific positioned note in the transaction.
158    #[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    /// Add an output note from this transaction.
166    ///
167    /// Any unused output value will be redirected back to the originating address as change notes.
168    #[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    /// Open a liquidity position in the order book.
176    #[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    /// Close a liquidity position in the order book.
183    #[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    /// Withdraw a liquidity position in the order book.
190    ///
191    /// Note: Currently this only supports an initial withdrawal from Closed, with no rewards.
192    #[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    /// Schedule a Dutch auction.
210    #[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    /// Ends a Dutch auction.
218    #[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    /// Withdraws the reserves of the Dutch auction.
225    ///
226    /// Uses the provided auction state to automatically end the auction
227    /// if necessary.
228    #[instrument(skip(self))]
229    pub fn dutch_auction_withdraw(&mut self, auction: &DutchAuction) -> &mut Self {
230        let auction_id = auction.description.id();
231        // Check if the auction needs to be ended
232        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, // 1 (closed) -> 2 (withdrawn)
248            reserves_input,
249            reserves_output,
250        };
251
252        self.action_list.push(plan);
253        self
254    }
255
256    /// Perform a swap based on input notes in the transaction.
257    #[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        // Determine the canonical order for the assets being swapped.
266        // This will determine whether the input amount is assigned to delta_1 or delta_2.
267        let trading_pair = TradingPair::new(input_value.asset_id, into_asset);
268
269        // If `trading_pair.asset_1` is the input asset, then `delta_1` is the input amount,
270        // and `delta_2` is 0.
271        //
272        // Otherwise, `delta_1` is 0, and `delta_2` is the input amount.
273        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 there is no input, then there is no swap.
280        if delta_1 == Amount::zero() && delta_2 == Amount::zero() {
281            anyhow::bail!("No input value for swap");
282        }
283
284        // Create the `SwapPlaintext` representing the swap to be performed:
285        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    /// Perform a swap claim based on an input swap with a pre-paid fee.
301    #[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    /// Add a delegation to this transaction.
308    #[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    /// Add an undelegation to this transaction.
321    #[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    /// Add an undelegate claim to this transaction.
334    #[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    /// Upload a validator definition in this transaction.
341    #[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    /// Submit a new governance proposal in this transaction.
348    #[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    /// Withdraw a governance proposal in this transaction.
358    #[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    /// Claim a governance proposal deposit in this transaction.
365    #[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    /// Deposit a value into the Community Pool.
381    #[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    /// Cast a validator vote in this transaction.
388    #[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    /// Perform an ICS-20 withdrawal
395    #[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    /// Perform an IBC action
402    #[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    /// Vote with all possible vote weight on a given proposal.
409    #[instrument(skip_all)]
410    pub async fn delegator_vote<V: ViewClient>(
411        // TODO this sucks, why isn't there a bundle of proposal data to use for voting
412        // how is that not the thing returned by the rpc? why do we have to query a bunch of shit
413        // independently and stitch it together?
414        &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        // 1. Create a DelegatorVotePlan for each votable note.
437        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            // 1. Create a DelegatorVotePlan that votes with this note on the proposal.
447            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        // 2. Here, we could sweep any spendable notes with delegation tokens to
460        // a new output to try to unlink them from a future vote.  In practice
461        // this is meaningless because we don't have flow encryption, so
462        // delegator votes reveal the precise amount, and this amount will
463        // likely be unique to the delegator and enough to link their votes.
464        // Also, because we're in a single transaction, the pattern of
465        // delegations will also be revealed (vs creating distinct transactions
466        // for each validator).
467        //
468        // So instead, we do nothing.
469
470        Ok(self)
471    }
472
473    /// Vote with a specific positioned note in the transaction, rather than automatically.
474    #[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    /// Prioritize notes to spend to release value of a specific transaction.
481    ///
482    /// Various logic is possible for note selection. Currently, this method
483    /// prioritizes notes sent to a one-time address, then notes with the largest
484    /// value:
485    ///
486    /// - Prioritizing notes sent to one-time addresses optimizes for a future in
487    /// which we implement DAGSync keyed by fuzzy message detection (which will not
488    /// be able to detect notes sent to one-time addresses). Spending these notes
489    /// immediately converts them into change notes, sent to the default address for
490    /// the users' account, which are detectable.
491    ///
492    /// - Prioritizing notes with the largest value optimizes for gas used by the
493    /// transaction.
494    ///
495    /// We may want to make note prioritization configurable in the future. For
496    /// instance, a user might prefer a note prioritization strategy that harvested
497    /// capital losses when possible, using cost basis information retained by the
498    /// view server.
499    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            // Sort by whether the note was sent to an ephemeral address...
509            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                // ... then by largest amount.
516                _ => b.note.amount().cmp(&a.note.amount()),
517            }
518        });
519        filtered
520    }
521
522    /// Add spends and change outputs as required to balance the transaction, using the view service
523    /// provided to supply the notes and other information.
524    pub async fn plan<V: ViewClient>(
525        &mut self,
526        view: &mut V,
527        mut source: AddressIndex,
528    ) -> anyhow::Result<TransactionPlan> {
529        // Wipe out the randomizer for the provided source, since
530        // 1. All randomizers correspond to the same account
531        // 2. Using one-time addresses for change addresses is undesirable.
532        source.randomizer = [0u8; 12];
533
534        // Compute the change address for this transaction.
535        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        // Phase 1, "process all of the user-supplied intents into complete
542        // action plans", has already happened using the builder API.
543        //
544        // Compute an initial fee estimate based on the actions we have so far.
545        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        // Phase 2: balance the transaction with information from the view service.
555        //
556        // It's possible that adding spends could increase the gas, increasing
557        // the fee amount, and so on, so we add spends iteratively. However, we
558        // need to query all the notes we'll use for planning upfront, so we
559        // don't accidentally try to use the same one twice.
560
561        let mut notes_by_asset_id = BTreeMap::new();
562        for required in self.action_list.balance_with_fee().required() {
563            // Find all the notes of this asset in the source account.
564            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        // Now iterate over the action list's imbalances to balance the transaction.
582        while let Some(required) = self.action_list.balance_with_fee().required().next() {
583            // Find a single note to spend towards the required balance.
584            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            // Add a spend for that note to the action list.
596            self.action_list
597                .push(SpendPlan::new(&mut OsRng, note.note, note.position));
598
599            // Refresh the fee estimate and change outputs.
600            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        // Construct the memo plan for the transaction, using user-specified data if it
616        // was provided.
617        let memo_plan = if self.action_list.requires_memo() {
618            let return_address = if let Some(ref address) = self.memo_return_address {
619                // Check that this address is actually controlled by the user.
620                // We don't have an FVK, so we have to ask the view service.
621                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        // Configure the transaction parameters with the chain ID.
640        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        // Fetch the FMD parameters that will be used to plan the transaction.
645        // (This really should have been considered witness data. Oh well.)
646        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        // Reset the planner in case it were reused. We don't want people to do that
656        // but otherwise we can't do builder method chaining with &mut self, and forcing
657        // the builder to move between calls is annoying for callers who are building up
658        // actions programmatically. Except we can't do a normal std::mem::replace here because
659        // the generic RNG mucks everything up. So it's just awful.
660        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}