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