penumbra_sdk_transaction/
plan.rs

1//! Declarative transaction plans, used for transaction authorization and
2//! creation.
3
4use anyhow::Result;
5use decaf377_fmd::Precision;
6use penumbra_sdk_community_pool::{CommunityPoolDeposit, CommunityPoolOutput, CommunityPoolSpend};
7use penumbra_sdk_dex::{
8    lp::{
9        action::PositionClose,
10        plan::{PositionOpenPlan, PositionWithdrawPlan},
11    },
12    swap::SwapPlan,
13    swap_claim::SwapClaimPlan,
14};
15use penumbra_sdk_funding::liquidity_tournament::ActionLiquidityTournamentVotePlan;
16use penumbra_sdk_governance::{
17    DelegatorVotePlan, ProposalDepositClaim, ProposalSubmit, ProposalWithdraw, ValidatorVote,
18};
19use penumbra_sdk_ibc::IbcRelay;
20use penumbra_sdk_keys::{Address, FullViewingKey, PayloadKey};
21use penumbra_sdk_proto::{core::transaction::v1 as pb, DomainType};
22use penumbra_sdk_shielded_pool::{Ics20Withdrawal, OutputPlan, SpendPlan};
23use penumbra_sdk_stake::{Delegate, Undelegate, UndelegateClaimPlan};
24use penumbra_sdk_txhash::{EffectHash, EffectingData};
25use rand::{CryptoRng, Rng};
26use serde::{Deserialize, Serialize};
27
28mod action;
29mod auth;
30mod build;
31mod clue;
32mod detection_data;
33mod memo;
34mod spend;
35
36pub use action::ActionPlan;
37pub use clue::CluePlan;
38pub use detection_data::DetectionDataPlan;
39pub use memo::MemoPlan;
40
41use crate::TransactionParameters;
42
43/// A declaration of a planned [`Transaction`](crate::Transaction),
44/// for use in transaction authorization and creation.
45#[derive(Clone, Debug, Default, Serialize, Deserialize)]
46#[serde(try_from = "pb::TransactionPlan", into = "pb::TransactionPlan")]
47pub struct TransactionPlan {
48    pub actions: Vec<ActionPlan>,
49    pub transaction_parameters: TransactionParameters,
50    pub detection_data: Option<DetectionDataPlan>,
51    pub memo: Option<MemoPlan>,
52}
53
54impl TransactionPlan {
55    /// Sort the actions in [`TransactionPlan`] by type, using the protobuf field number in the [`ActionPlan`].
56    pub fn sort_actions(&mut self) {
57        self.actions
58            .sort_by_key(|action: &ActionPlan| action.variant_index());
59    }
60
61    /// Computes the [`EffectHash`] for the [`Transaction`] described by this
62    /// [`TransactionPlan`].
63    ///
64    /// This method does not require constructing the entire [`Transaction`],
65    /// but it does require the associated [`FullViewingKey`] to derive
66    /// effecting data that will be fed into the [`EffectHash`].
67    ///
68    /// This method is not an [`EffectingData`] impl because it needs an extra input,
69    /// the FVK, to partially construct the transaction.
70    pub fn effect_hash(&self, fvk: &FullViewingKey) -> Result<EffectHash> {
71        // This implementation is identical to the one for Transaction, except that we
72        // don't need to actually construct the entire `TransactionBody` with
73        // complete `Action`s, we just need to construct the bodies of the
74        // actions the transaction will have when constructed.
75
76        let mut state = blake2b_simd::Params::new()
77            .personal(b"PenumbraEfHs")
78            .to_state();
79
80        let parameters_hash = self.transaction_parameters.effect_hash();
81
82        let memo_hash = match self.memo {
83            Some(ref memo) => memo.memo()?.effect_hash(),
84            None => EffectHash::default(),
85        };
86
87        let detection_data_hash = self
88            .detection_data
89            .as_ref()
90            .map(|plan| plan.detection_data().effect_hash())
91            // If the detection data is not present, use the all-zero hash to
92            // record its absence in the overall effect hash.
93            .unwrap_or_default();
94
95        // Hash the fixed data of the transaction body.
96        state.update(parameters_hash.as_bytes());
97        state.update(memo_hash.as_bytes());
98        state.update(detection_data_hash.as_bytes());
99
100        // Hash the number of actions, then each action.
101        let num_actions = self.actions.len() as u32;
102        state.update(&num_actions.to_le_bytes());
103
104        // If the memo_key is None, then there is no memo, so there will be no
105        // outputs that the memo key is passed to, so we can fill in a dummy key.
106        let memo_key = self.memo_key().unwrap_or([0u8; 32].into());
107
108        // Hash the effecting data of each action, in the order it appears in the plan,
109        // which will be the order it appears in the transaction.
110        for action_plan in &self.actions {
111            state.update(action_plan.effect_hash(fvk, &memo_key).as_bytes());
112        }
113
114        Ok(EffectHash(state.finalize().as_array().clone()))
115    }
116
117    pub fn spend_plans(&self) -> impl Iterator<Item = &SpendPlan> {
118        self.actions.iter().filter_map(|action| {
119            if let ActionPlan::Spend(s) = action {
120                Some(s)
121            } else {
122                None
123            }
124        })
125    }
126
127    pub fn output_plans(&self) -> impl Iterator<Item = &OutputPlan> {
128        self.actions.iter().filter_map(|action| {
129            if let ActionPlan::Output(o) = action {
130                Some(o)
131            } else {
132                None
133            }
134        })
135    }
136
137    pub fn delegations(&self) -> impl Iterator<Item = &Delegate> {
138        self.actions.iter().filter_map(|action| {
139            if let ActionPlan::Delegate(d) = action {
140                Some(d)
141            } else {
142                None
143            }
144        })
145    }
146
147    pub fn undelegations(&self) -> impl Iterator<Item = &Undelegate> {
148        self.actions.iter().filter_map(|action| {
149            if let ActionPlan::Undelegate(d) = action {
150                Some(d)
151            } else {
152                None
153            }
154        })
155    }
156
157    pub fn undelegate_claim_plans(&self) -> impl Iterator<Item = &UndelegateClaimPlan> {
158        self.actions.iter().filter_map(|action| {
159            if let ActionPlan::UndelegateClaim(d) = action {
160                Some(d)
161            } else {
162                None
163            }
164        })
165    }
166
167    pub fn ibc_actions(&self) -> impl Iterator<Item = &IbcRelay> {
168        self.actions.iter().filter_map(|action| {
169            if let ActionPlan::IbcAction(ibc_action) = action {
170                Some(ibc_action)
171            } else {
172                None
173            }
174        })
175    }
176
177    pub fn validator_definitions(
178        &self,
179    ) -> impl Iterator<Item = &penumbra_sdk_stake::validator::Definition> {
180        self.actions.iter().filter_map(|action| {
181            if let ActionPlan::ValidatorDefinition(d) = action {
182                Some(d)
183            } else {
184                None
185            }
186        })
187    }
188
189    pub fn proposal_submits(&self) -> impl Iterator<Item = &ProposalSubmit> {
190        self.actions.iter().filter_map(|action| {
191            if let ActionPlan::ProposalSubmit(p) = action {
192                Some(p)
193            } else {
194                None
195            }
196        })
197    }
198
199    pub fn proposal_withdraws(&self) -> impl Iterator<Item = &ProposalWithdraw> {
200        self.actions.iter().filter_map(|action| {
201            if let ActionPlan::ProposalWithdraw(p) = action {
202                Some(p)
203            } else {
204                None
205            }
206        })
207    }
208
209    pub fn delegator_vote_plans(&self) -> impl Iterator<Item = &DelegatorVotePlan> {
210        self.actions.iter().filter_map(|action| {
211            if let ActionPlan::DelegatorVote(v) = action {
212                Some(v)
213            } else {
214                None
215            }
216        })
217    }
218
219    pub fn lqt_vote_plans(&self) -> impl Iterator<Item = &ActionLiquidityTournamentVotePlan> {
220        self.actions.iter().filter_map(|action| {
221            if let ActionPlan::ActionLiquidityTournamentVote(v) = action {
222                Some(v)
223            } else {
224                None
225            }
226        })
227    }
228
229    pub fn validator_votes(&self) -> impl Iterator<Item = &ValidatorVote> {
230        self.actions.iter().filter_map(|action| {
231            if let ActionPlan::ValidatorVote(v) = action {
232                Some(v)
233            } else {
234                None
235            }
236        })
237    }
238
239    pub fn proposal_deposit_claims(&self) -> impl Iterator<Item = &ProposalDepositClaim> {
240        self.actions.iter().filter_map(|action| {
241            if let ActionPlan::ProposalDepositClaim(p) = action {
242                Some(p)
243            } else {
244                None
245            }
246        })
247    }
248
249    pub fn swap_plans(&self) -> impl Iterator<Item = &SwapPlan> {
250        self.actions.iter().filter_map(|action| {
251            if let ActionPlan::Swap(v) = action {
252                Some(v)
253            } else {
254                None
255            }
256        })
257    }
258
259    pub fn swap_claim_plans(&self) -> impl Iterator<Item = &SwapClaimPlan> {
260        self.actions.iter().filter_map(|action| {
261            if let ActionPlan::SwapClaim(v) = action {
262                Some(v)
263            } else {
264                None
265            }
266        })
267    }
268
269    pub fn community_pool_spends(&self) -> impl Iterator<Item = &CommunityPoolSpend> {
270        self.actions.iter().filter_map(|action| {
271            if let ActionPlan::CommunityPoolSpend(v) = action {
272                Some(v)
273            } else {
274                None
275            }
276        })
277    }
278
279    pub fn community_pool_deposits(&self) -> impl Iterator<Item = &CommunityPoolDeposit> {
280        self.actions.iter().filter_map(|action| {
281            if let ActionPlan::CommunityPoolDeposit(v) = action {
282                Some(v)
283            } else {
284                None
285            }
286        })
287    }
288
289    pub fn community_pool_outputs(&self) -> impl Iterator<Item = &CommunityPoolOutput> {
290        self.actions.iter().filter_map(|action| {
291            if let ActionPlan::CommunityPoolOutput(v) = action {
292                Some(v)
293            } else {
294                None
295            }
296        })
297    }
298
299    pub fn position_openings(&self) -> impl Iterator<Item = &PositionOpenPlan> {
300        self.actions.iter().filter_map(|action| {
301            if let ActionPlan::PositionOpen(v) = action {
302                Some(v)
303            } else {
304                None
305            }
306        })
307    }
308
309    pub fn position_closings(&self) -> impl Iterator<Item = &PositionClose> {
310        self.actions.iter().filter_map(|action| {
311            if let ActionPlan::PositionClose(v) = action {
312                Some(v)
313            } else {
314                None
315            }
316        })
317    }
318
319    pub fn position_withdrawals(&self) -> impl Iterator<Item = &PositionWithdrawPlan> {
320        self.actions.iter().filter_map(|action| {
321            if let ActionPlan::PositionWithdraw(v) = action {
322                Some(v)
323            } else {
324                None
325            }
326        })
327    }
328
329    pub fn ics20_withdrawals(&self) -> impl Iterator<Item = &Ics20Withdrawal> {
330        self.actions.iter().filter_map(|action| {
331            if let ActionPlan::Ics20Withdrawal(v) = action {
332                Some(v)
333            } else {
334                None
335            }
336        })
337    }
338
339    /// Convenience method to get all the destination addresses for each `OutputPlan`s.
340    pub fn dest_addresses(&self) -> Vec<Address> {
341        self.output_plans()
342            .map(|plan| plan.dest_address.clone())
343            .collect()
344    }
345
346    /// Convenience method to get the number of `OutputPlan`s in this transaction.
347    pub fn num_outputs(&self) -> usize {
348        self.output_plans().count()
349    }
350
351    /// Convenience method to get the number of `SpendPlan`s in this transaction.
352    pub fn num_spends(&self) -> usize {
353        self.spend_plans().count()
354    }
355
356    /// Convenience method to get the number of proofs in this transaction.
357    pub fn num_proofs(&self) -> usize {
358        self.actions
359            .iter()
360            .map(|action| match action {
361                ActionPlan::Spend(_) => 1,
362                ActionPlan::Output(_) => 1,
363                ActionPlan::Swap(_) => 1,
364                ActionPlan::SwapClaim(_) => 1,
365                ActionPlan::UndelegateClaim(_) => 1,
366                ActionPlan::DelegatorVote(_) => 1,
367                _ => 0,
368            })
369            .sum()
370    }
371
372    /// Method to populate the detection data for this transaction plan.
373    pub fn populate_detection_data<R: CryptoRng + Rng>(
374        &mut self,
375        mut rng: R,
376        precision: Precision,
377    ) {
378        // Add one clue per recipient.
379        let mut clue_plans = vec![];
380        for dest_address in self.dest_addresses() {
381            clue_plans.push(CluePlan::new(&mut rng, dest_address, precision));
382        }
383
384        // Now add dummy clues until we have one clue per output.
385        let num_dummy_clues = self.num_outputs() - clue_plans.len();
386        for _ in 0..num_dummy_clues {
387            let dummy_address = Address::dummy(&mut rng);
388            clue_plans.push(CluePlan::new(&mut rng, dummy_address, precision));
389        }
390
391        if !clue_plans.is_empty() {
392            self.detection_data = Some(DetectionDataPlan { clue_plans });
393        } else {
394            self.detection_data = None;
395        }
396    }
397
398    /// A builder-style version of [`TransactionPlan::populate_detection_data()`].
399    ///
400    /// Populates the detection data for this transaction plan.
401    pub fn with_populated_detection_data<R: CryptoRng + Rng>(
402        mut self,
403        rng: R,
404        precision_bits: Precision,
405    ) -> Self {
406        self.populate_detection_data(rng, precision_bits);
407        self
408    }
409
410    /// Convenience method to grab the `MemoKey` from the plan.
411    pub fn memo_key(&self) -> Option<PayloadKey> {
412        self.memo.as_ref().map(|memo_plan| memo_plan.key.clone())
413    }
414}
415
416impl DomainType for TransactionPlan {
417    type Proto = pb::TransactionPlan;
418}
419
420impl From<TransactionPlan> for pb::TransactionPlan {
421    fn from(msg: TransactionPlan) -> Self {
422        Self {
423            actions: msg.actions.into_iter().map(Into::into).collect(),
424            transaction_parameters: Some(msg.transaction_parameters.into()),
425            detection_data: msg.detection_data.map(Into::into),
426            memo: msg.memo.map(Into::into),
427        }
428    }
429}
430
431impl TryFrom<pb::TransactionPlan> for TransactionPlan {
432    type Error = anyhow::Error;
433    fn try_from(value: pb::TransactionPlan) -> Result<Self, Self::Error> {
434        Ok(Self {
435            actions: value
436                .actions
437                .into_iter()
438                .map(TryInto::try_into)
439                .collect::<Result<_, _>>()?,
440            transaction_parameters: value
441                .transaction_parameters
442                .ok_or_else(|| anyhow::anyhow!("transaction plan missing transaction parameters"))?
443                .try_into()?,
444            detection_data: value.detection_data.map(TryInto::try_into).transpose()?,
445            memo: value.memo.map(TryInto::try_into).transpose()?,
446        })
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use penumbra_sdk_asset::{asset, Value, STAKING_TOKEN_ASSET_ID};
453    use penumbra_sdk_dex::{swap::SwapPlaintext, swap::SwapPlan, TradingPair};
454    use penumbra_sdk_fee::Fee;
455    use penumbra_sdk_keys::{
456        keys::{Bip44Path, SeedPhrase, SpendKey},
457        Address,
458    };
459    use penumbra_sdk_shielded_pool::Note;
460    use penumbra_sdk_shielded_pool::{OutputPlan, SpendPlan};
461    use penumbra_sdk_tct as tct;
462    use penumbra_sdk_txhash::EffectingData as _;
463    use rand_core::OsRng;
464
465    use crate::{
466        memo::MemoPlaintext,
467        plan::{CluePlan, DetectionDataPlan, MemoPlan, TransactionPlan},
468        TransactionParameters, WitnessData,
469    };
470
471    /// This isn't an exhaustive test, but we don't currently have a
472    /// great way to generate actions for randomized testing.
473    ///
474    /// All we hope to check here is that, for a basic transaction plan,
475    /// we compute the same auth hash for the plan and for the transaction.
476    #[test]
477    fn plan_effect_hash_matches_transaction_effect_hash() {
478        let rng = OsRng;
479        let seed_phrase = SeedPhrase::generate(rng);
480        let sk = SpendKey::from_seed_phrase_bip44(seed_phrase, &Bip44Path::new(0));
481        let fvk = sk.full_viewing_key();
482        let (addr, _dtk) = fvk.incoming().payment_address(0u32.into());
483
484        let mut sct = tct::Tree::new();
485
486        let note0 = Note::generate(
487            &mut OsRng,
488            &addr,
489            Value {
490                amount: 10000u64.into(),
491                asset_id: *STAKING_TOKEN_ASSET_ID,
492            },
493        );
494        let note1 = Note::generate(
495            &mut OsRng,
496            &addr,
497            Value {
498                amount: 20000u64.into(),
499                asset_id: *STAKING_TOKEN_ASSET_ID,
500            },
501        );
502
503        sct.insert(tct::Witness::Keep, note0.commit()).unwrap();
504        sct.insert(tct::Witness::Keep, note1.commit()).unwrap();
505
506        let trading_pair = TradingPair::new(
507            asset::Cache::with_known_assets()
508                .get_unit("nala")
509                .unwrap()
510                .id(),
511            asset::Cache::with_known_assets()
512                .get_unit("upenumbra")
513                .unwrap()
514                .id(),
515        );
516
517        let swap_plaintext = SwapPlaintext::new(
518            &mut OsRng,
519            trading_pair,
520            100000u64.into(),
521            1u64.into(),
522            Fee(Value {
523                amount: 3u64.into(),
524                asset_id: asset::Cache::with_known_assets()
525                    .get_unit("upenumbra")
526                    .unwrap()
527                    .id(),
528            }),
529            addr.clone(),
530        );
531
532        let mut rng = OsRng;
533
534        let memo_plaintext = MemoPlaintext::new(Address::dummy(&mut rng), "".to_string()).unwrap();
535        let mut plan: TransactionPlan = TransactionPlan {
536            // Put outputs first to check that the auth hash
537            // computation is not affected by plan ordering.
538            actions: vec![
539                OutputPlan::new(
540                    &mut OsRng,
541                    Value {
542                        amount: 30000u64.into(),
543                        asset_id: *STAKING_TOKEN_ASSET_ID,
544                    },
545                    addr.clone(),
546                )
547                .into(),
548                SpendPlan::new(&mut OsRng, note0, 0u64.into()).into(),
549                SpendPlan::new(&mut OsRng, note1, 1u64.into()).into(),
550                SwapPlan::new(&mut OsRng, swap_plaintext).into(),
551            ],
552            transaction_parameters: TransactionParameters {
553                expiry_height: 0,
554                fee: Fee::default(),
555                chain_id: "penumbra-test".to_string(),
556            },
557            detection_data: Some(DetectionDataPlan {
558                clue_plans: vec![CluePlan::new(&mut OsRng, addr, 1.try_into().unwrap())],
559            }),
560            memo: Some(MemoPlan::new(&mut OsRng, memo_plaintext.clone())),
561        };
562
563        // Sort actions within the transaction plan.
564        plan.sort_actions();
565
566        println!("{}", serde_json::to_string_pretty(&plan).unwrap());
567
568        let plan_effect_hash = plan.effect_hash(fvk).unwrap();
569
570        let auth_data = plan.authorize(rng, &sk).unwrap();
571        let witness_data = WitnessData {
572            anchor: sct.root(),
573            state_commitment_proofs: plan
574                .spend_plans()
575                .map(|spend: &SpendPlan| {
576                    (
577                        spend.note.commit(),
578                        sct.witness(spend.note.commit()).unwrap(),
579                    )
580                })
581                .collect(),
582        };
583        let transaction = plan.build(fvk, &witness_data, &auth_data).unwrap();
584
585        let transaction_effect_hash = transaction.effect_hash();
586
587        assert_eq!(plan_effect_hash, transaction_effect_hash);
588
589        let decrypted_memo = transaction.decrypt_memo(fvk).expect("can decrypt memo");
590        assert_eq!(decrypted_memo, memo_plaintext);
591
592        // TODO: fix this and move into its own test?
593        // // Also check the concurrent build results in the same effect hash.
594        // let rt = Runtime::new().unwrap();
595        // let transaction = rt
596        //     .block_on(async move {
597        //         plan.build_concurrent(&mut OsRng, fvk, auth_data, witness_data)
598        //             .await
599        //     })
600        //     .expect("can build");
601        // assert_eq!(plan_effect_hash, transaction.effect_hash());
602    }
603}