penumbra_sdk_transaction/
plan.rs
1use 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#[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 pub fn sort_actions(&mut self) {
57 self.actions
58 .sort_by_key(|action: &ActionPlan| action.variant_index());
59 }
60
61 pub fn effect_hash(&self, fvk: &FullViewingKey) -> Result<EffectHash> {
71 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 .unwrap_or_default();
94
95 state.update(parameters_hash.as_bytes());
97 state.update(memo_hash.as_bytes());
98 state.update(detection_data_hash.as_bytes());
99
100 let num_actions = self.actions.len() as u32;
102 state.update(&num_actions.to_le_bytes());
103
104 let memo_key = self.memo_key().unwrap_or([0u8; 32].into());
107
108 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 pub fn dest_addresses(&self) -> Vec<Address> {
341 self.output_plans()
342 .map(|plan| plan.dest_address.clone())
343 .collect()
344 }
345
346 pub fn num_outputs(&self) -> usize {
348 self.output_plans().count()
349 }
350
351 pub fn num_spends(&self) -> usize {
353 self.spend_plans().count()
354 }
355
356 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 pub fn populate_detection_data<R: CryptoRng + Rng>(
374 &mut self,
375 mut rng: R,
376 precision: Precision,
377 ) {
378 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 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 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 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 #[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 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 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 }
603}