penumbra_sdk_transaction/
plan.rs1use 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#[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 pub fn sort_actions(&mut self) {
54 self.actions
55 .sort_by_key(|action: &ActionPlan| action.variant_index());
56 }
57
58 pub fn effect_hash(&self, fvk: &FullViewingKey) -> Result<EffectHash> {
68 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 .unwrap_or_default();
91
92 state.update(parameters_hash.as_bytes());
94 state.update(memo_hash.as_bytes());
95 state.update(detection_data_hash.as_bytes());
96
97 let num_actions = self.actions.len() as u32;
99 state.update(&num_actions.to_le_bytes());
100
101 let memo_key = self.memo_key().unwrap_or([0u8; 32].into());
104
105 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 pub fn dest_addresses(&self) -> Vec<Address> {
328 self.output_plans()
329 .map(|plan| plan.dest_address.clone())
330 .collect()
331 }
332
333 pub fn num_outputs(&self) -> usize {
335 self.output_plans().count()
336 }
337
338 pub fn num_spends(&self) -> usize {
340 self.spend_plans().count()
341 }
342
343 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 pub fn populate_detection_data<R: CryptoRng + Rng>(
361 &mut self,
362 mut rng: R,
363 precision: Precision,
364 ) {
365 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 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 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 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 #[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 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 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 }
590}