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_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
56/// A planner for a [`TransactionPlan`] that can fill in the required spends and change outputs upon
57/// finalization to make a transaction balance.
58pub struct Planner<R: RngCore + CryptoRng> {
59    rng: R,
60    action_list: ActionList,
61    /// The fee tier to apply to this transaction.
62    fee_tier: FeeTier,
63    /// The set of prices used for gas estimation.
64    gas_prices: Option<GasPrices>,
65    /// The transaction parameters to use for the transaction.
66    transaction_parameters: TransactionParameters,
67    /// A user-specified change address, if any.
68    change_address: Option<Address>,
69    /// A user-specified memo text, if any.
70    memo_text: Option<String>,
71    /// A user-specified memo return address, if any.
72    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    /// Create a new planner.
91    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    /// Add an arbitrary action to the planner.
105    pub fn action<A: Into<ActionPlan>>(&mut self, action: A) -> &mut Self {
106        self.action_list.push(action);
107        self
108    }
109
110    /// Set the current gas prices for fee prediction.
111    #[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    /// Set the fee tier.
118    #[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    /// Set the expiry height for the transaction.
125    #[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    /// Set a human-readable memo text for the transaction.
132    ///
133    /// Errors if the memo is too long.
134    #[instrument(skip(self))]
135    pub fn memo(&mut self, text: String) -> &mut Self {
136        self.memo_text = Some(text);
137        self
138    }
139
140    /// Customize the return address for the memo.
141    ///
142    /// If unset, this will default to the address for the source account.  This
143    /// must be an address controlled by the user, as the expectation is that
144    /// the recipient can use the address to transact with the sender.
145    #[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    /// Set the change address for the transaction.
152    ///
153    /// If unset, this will default to the address for the source account.
154    ///
155    /// This can be a foreign address, allowing "send max" functionality.
156    #[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    /// Spend a specific positioned note in the transaction.
163    #[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    /// Add an output note from this transaction.
171    ///
172    /// Any unused output value will be redirected back to the originating address as change notes.
173    #[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    /// Open a liquidity position in the order book.
181    #[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    /// Open a liquidity position in the order book.
191    #[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    /// Close a liquidity position in the order book.
205    #[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    /// Withdraw a liquidity position in the order book.
212    ///
213    /// Note: Currently this only supports an initial withdrawal from Closed, with no rewards.
214    #[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    /// Schedule a Dutch auction.
233    #[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    /// Ends a Dutch auction.
241    #[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    /// Withdraws the reserves of the Dutch auction.
248    ///
249    /// Uses the provided auction state to automatically end the auction
250    /// if necessary.
251    #[instrument(skip(self))]
252    pub fn dutch_auction_withdraw(&mut self, auction: &DutchAuction) -> &mut Self {
253        let auction_id = auction.description.id();
254        // Check if the auction needs to be ended
255        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, // 1 (closed) -> 2 (withdrawn)
271            reserves_input,
272            reserves_output,
273        };
274
275        self.action_list.push(plan);
276        self
277    }
278
279    /// Perform a swap based on input notes in the transaction.
280    #[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        // Determine the canonical order for the assets being swapped.
289        // This will determine whether the input amount is assigned to delta_1 or delta_2.
290        let trading_pair = TradingPair::new(input_value.asset_id, into_asset);
291
292        // If `trading_pair.asset_1` is the input asset, then `delta_1` is the input amount,
293        // and `delta_2` is 0.
294        //
295        // Otherwise, `delta_1` is 0, and `delta_2` is the input amount.
296        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 there is no input, then there is no swap.
303        if delta_1 == Amount::zero() && delta_2 == Amount::zero() {
304            anyhow::bail!("No input value for swap");
305        }
306
307        // Create the `SwapPlaintext` representing the swap to be performed:
308        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    /// Perform a swap claim based on an input swap with a pre-paid fee.
324    #[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    /// Add a delegation to this transaction.
331    #[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    /// Add an undelegation to this transaction.
344    #[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    /// Add an undelegate claim to this transaction.
357    #[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    /// Upload a validator definition in this transaction.
364    #[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    /// Submit a new governance proposal in this transaction.
371    #[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    /// Withdraw a governance proposal in this transaction.
381    #[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    /// Claim a governance proposal deposit in this transaction.
388    #[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    /// Deposit a value into the Community Pool.
404    #[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    /// Cast a validator vote in this transaction.
411    #[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    /// Perform an ICS-20 withdrawal
418    #[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    /// Perform an IBC action
425    #[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    /// Vote with all possible vote weight on a given proposal.
432    #[instrument(skip_all)]
433    pub async fn delegator_vote<V: ViewClient>(
434        // TODO this sucks, why isn't there a bundle of proposal data to use for voting
435        // how is that not the thing returned by the rpc? why do we have to query a bunch of shit
436        // independently and stitch it together?
437        &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        // 1. Create a DelegatorVotePlan for each votable note.
460        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            // 1. Create a DelegatorVotePlan that votes with this note on the proposal.
470            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        // 2. Here, we could sweep any spendable notes with delegation tokens to
483        // a new output to try to unlink them from a future vote.  In practice
484        // this is meaningless because we don't have flow encryption, so
485        // delegator votes reveal the precise amount, and this amount will
486        // likely be unique to the delegator and enough to link their votes.
487        // Also, because we're in a single transaction, the pattern of
488        // delegations will also be revealed (vs creating distinct transactions
489        // for each validator).
490        //
491        // So instead, we do nothing.
492
493        Ok(self)
494    }
495
496    /// Vote with a specific positioned note in the transaction, rather than automatically.
497    #[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    /// Prioritize notes to spend to release value of a specific transaction.
527    ///
528    /// Various logic is possible for note selection. Currently, this method
529    /// prioritizes notes sent to a one-time address, then notes with the largest
530    /// value:
531    ///
532    /// - Prioritizing notes sent to one-time addresses optimizes for a future in
533    /// which we implement DAGSync keyed by fuzzy message detection (which will not
534    /// be able to detect notes sent to one-time addresses). Spending these notes
535    /// immediately converts them into change notes, sent to the default address for
536    /// the users' account, which are detectable.
537    ///
538    /// - Prioritizing notes with the largest value optimizes for gas used by the
539    /// transaction.
540    ///
541    /// We may want to make note prioritization configurable in the future. For
542    /// instance, a user might prefer a note prioritization strategy that harvested
543    /// capital losses when possible, using cost basis information retained by the
544    /// view server.
545    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            // Sort by whether the note was sent to an ephemeral address...
555            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                // ... then by largest amount.
562                _ => b.note.amount().cmp(&a.note.amount()),
563            }
564        });
565        filtered
566    }
567
568    /// Add spends and change outputs as required to balance the transaction, using the view service
569    /// provided to supply the notes and other information.
570    pub async fn plan<V: ViewClient + ?Sized>(
571        &mut self,
572        view: &mut V,
573        mut source: AddressIndex,
574    ) -> anyhow::Result<TransactionPlan> {
575        // Wipe out the randomizer for the provided source, since
576        // 1. All randomizers correspond to the same account
577        // 2. Using one-time addresses for change addresses is undesirable.
578        source.randomizer = [0u8; 12];
579
580        // Compute the change address for this transaction.
581        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        // Phase 1, "process all of the user-supplied intents into complete
588        // action plans", has already happened using the builder API.
589        //
590        // Compute an initial fee estimate based on the actions we have so far.
591        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        // Phase 2: balance the transaction with information from the view service.
601        //
602        // It's possible that adding spends could increase the gas, increasing
603        // the fee amount, and so on, so we add spends iteratively. However, we
604        // need to query all the notes we'll use for planning upfront, so we
605        // don't accidentally try to use the same one twice.
606
607        let mut notes_by_asset_id = BTreeMap::new();
608        for required in self.action_list.balance_with_fee().required() {
609            // Find all the notes of this asset in the source account.
610            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        // Now iterate over the action list's imbalances to balance the transaction.
628        while let Some(required) = self.action_list.balance_with_fee().required().next() {
629            // Find a single note to spend towards the required balance.
630            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            // Add a spend for that note to the action list.
642            self.action_list
643                .push(SpendPlan::new(&mut OsRng, note.note, note.position));
644
645            // Refresh the fee estimate and change outputs.
646            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        // Construct the memo plan for the transaction, using user-specified data if it
662        // was provided.
663        let memo_plan = if self.action_list.requires_memo() {
664            let return_address = if let Some(ref address) = self.memo_return_address {
665                // Check that this address is actually controlled by the user.
666                // We don't have an FVK, so we have to ask the view service.
667                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        // Configure the transaction parameters with the chain ID.
686        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        // Fetch the FMD parameters that will be used to plan the transaction.
691        // (This really should have been considered witness data. Oh well.)
692        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        // Reset the planner in case it were reused. We don't want people to do that
702        // but otherwise we can't do builder method chaining with &mut self, and forcing
703        // the builder to move between calls is annoying for callers who are building up
704        // actions programmatically. Except we can't do a normal std::mem::replace here because
705        // the generic RNG mucks everything up. So it's just awful.
706        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}