penumbra_sdk_app/action_handler/
transaction.rs

1use std::sync::Arc;
2
3use anyhow::Result;
4use async_trait::async_trait;
5use cnidarium::{StateRead, StateWrite};
6use penumbra_sdk_fee::component::FeePay as _;
7use penumbra_sdk_sct::{component::source::SourceContext, CommitmentSource};
8use penumbra_sdk_shielded_pool::component::ClueManager;
9use penumbra_sdk_transaction::{gas::GasCost as _, Transaction};
10use tokio::task::JoinSet;
11use tracing::{instrument, Instrument};
12
13use super::AppActionHandler;
14
15mod stateful;
16mod stateless;
17
18use self::stateful::{
19    claimed_anchor_is_valid, fmd_parameters_valid, tx_parameters_historical_check,
20};
21use stateless::{
22    check_memo_exists_if_outputs_absent_if_not, check_non_empty_transaction,
23    num_clues_equal_to_num_outputs, valid_binding_signature,
24};
25
26#[async_trait]
27impl AppActionHandler for Transaction {
28    type CheckStatelessContext = ();
29
30    // We only instrument the top-level `check_stateless`, so we get one span for each transaction.
31    #[instrument(skip(self, _context))]
32    async fn check_stateless(&self, _context: ()) -> Result<()> {
33        // This check should be done first, and complete before all other
34        // stateless checks, like proof verification.  In addition to proving
35        // that value balances, the binding signature binds the proofs to the
36        // transaction, as the binding signature can only be created with
37        // knowledge of all of the openings to the commitments the transaction
38        // makes proofs against. (This is where the name binding signature comes
39        // from).
40        //
41        // This allows us to cheaply eliminate a large class of invalid
42        // transactions upfront -- past this point, we can be sure that the user
43        // who submitted the transaction actually formed the proofs, rather than
44        // replaying them from another transaction.
45        valid_binding_signature(self)?;
46        // Other checks probably too cheap to be worth splitting into tasks.
47        num_clues_equal_to_num_outputs(self)?;
48        check_memo_exists_if_outputs_absent_if_not(self)?;
49        // This check ensures that transactions contain at least one action.
50        check_non_empty_transaction(self)?;
51
52        let context = self.context();
53
54        // Currently, we need to clone the component actions so that the spawned
55        // futures can have 'static lifetimes. In the future, we could try to
56        // use the yoke crate, but cloning is almost certainly not a big deal
57        // for now.
58        let mut action_checks = JoinSet::new();
59        for (i, action) in self.actions().cloned().enumerate() {
60            let context2 = context.clone();
61            let span = action.create_span(i);
62            action_checks
63                .spawn(async move { action.check_stateless(context2).await }.instrument(span));
64        }
65        // Now check if any component action failed verification.
66        while let Some(check) = action_checks.join_next().await {
67            check??;
68        }
69
70        Ok(())
71    }
72
73    // We only instrument the top-level `check_stateful`, so we get one span for each transaction.
74    #[instrument(skip(self, state))]
75    async fn check_historical<S: StateRead + 'static>(&self, state: Arc<S>) -> Result<()> {
76        let mut action_checks = JoinSet::new();
77
78        // SAFETY: Transaction parameters (chain id, expiry height) against chain state
79        // that cannot change during transaction execution.
80        // The fee is _not_ checked here, but during execution.
81        tx_parameters_historical_check(state.clone(), self).await?;
82        // SAFETY: anchors are historical data and cannot change during transaction execution.
83        claimed_anchor_is_valid(state.clone(), self).await?;
84        // SAFETY: FMD parameters cannot change during transaction execution.
85        fmd_parameters_valid(state.clone(), self).await?;
86
87        // Currently, we need to clone the component actions so that the spawned
88        // futures can have 'static lifetimes. In the future, we could try to
89        // use the yoke crate, but cloning is almost certainly not a big deal
90        // for now.
91        for (i, action) in self.actions().cloned().enumerate() {
92            let state2 = state.clone();
93            let span = action.create_span(i);
94            action_checks
95                .spawn(async move { action.check_historical(state2).await }.instrument(span));
96        }
97        // Now check if any component action failed verification.
98        while let Some(check) = action_checks.join_next().await {
99            check??;
100        }
101
102        Ok(())
103    }
104
105    // We only instrument the top-level `execute`, so we get one span for each transaction.
106    #[instrument(skip(self, state))]
107    async fn check_and_execute<S: StateWrite>(&self, mut state: S) -> Result<()> {
108        // While we have access to the full Transaction, hash it to
109        // obtain a NoteSource we can cache for various actions.
110        let source = CommitmentSource::Transaction {
111            id: Some(self.id().0),
112        };
113        state.put_current_source(Some(source));
114
115        // Check and record the transaction's fee payment,
116        // before doing the rest of execution.
117        let gas_used = self.gas_cost();
118        let fee = self.transaction_body.transaction_parameters.fee;
119        state.pay_fee(gas_used, fee).await?;
120
121        for (i, action) in self.actions().enumerate() {
122            let span = action.create_span(i);
123            action
124                .check_and_execute(&mut state)
125                .instrument(span)
126                .await?;
127        }
128
129        // Delete the note source, in case someone else tries to read it.
130        state.put_current_source(None);
131
132        // Record all the clues in this transaction
133        // To avoid recomputing a hash.
134        let id = self.id();
135        for clue in self
136            .transaction_body
137            .detection_data
138            .iter()
139            .flat_map(|x| x.fmd_clues.iter())
140        {
141            state.record_clue(clue.clone(), id.clone()).await?;
142        }
143
144        Ok(())
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use std::ops::Deref;
151
152    use anyhow::Result;
153    use penumbra_sdk_asset::{Value, STAKING_TOKEN_ASSET_ID};
154    use penumbra_sdk_fee::Fee;
155    use penumbra_sdk_keys::test_keys;
156    use penumbra_sdk_shielded_pool::{Note, OutputPlan, SpendPlan};
157    use penumbra_sdk_tct as tct;
158    use penumbra_sdk_transaction::{
159        plan::{CluePlan, DetectionDataPlan, TransactionPlan},
160        TransactionParameters, WitnessData,
161    };
162    use rand_core::OsRng;
163
164    use crate::AppActionHandler;
165
166    #[tokio::test]
167    async fn check_stateless_succeeds_on_valid_spend() -> Result<()> {
168        // Generate two notes controlled by the test address.
169        let value = Value {
170            amount: 100u64.into(),
171            asset_id: *STAKING_TOKEN_ASSET_ID,
172        };
173        let note = Note::generate(&mut OsRng, &test_keys::ADDRESS_0, value);
174        let value2 = Value {
175            amount: 50u64.into(),
176            asset_id: *STAKING_TOKEN_ASSET_ID,
177        };
178        let note2 = Note::generate(&mut OsRng, &test_keys::ADDRESS_0, value2);
179
180        // Record that note in an SCT, where we can generate an auth path.
181        let mut sct = tct::Tree::new();
182        // Assume there's a bunch of stuff already in the SCT.
183        for _ in 0..5 {
184            let random_note = Note::generate(&mut OsRng, &test_keys::ADDRESS_0, value);
185            sct.insert(tct::Witness::Keep, random_note.commit())
186                .unwrap();
187        }
188        sct.insert(tct::Witness::Keep, note.commit()).unwrap();
189        sct.insert(tct::Witness::Keep, note2.commit()).unwrap();
190        // Do we want to seal the SCT block here?
191        let auth_path = sct.witness(note.commit()).unwrap();
192        let auth_path2 = sct.witness(note2.commit()).unwrap();
193
194        // Add a single spend and output to the transaction plan such that the
195        // transaction balances.
196        let plan = TransactionPlan {
197            transaction_parameters: TransactionParameters {
198                expiry_height: 0,
199                fee: Fee::default(),
200                chain_id: "".into(),
201            },
202            actions: vec![
203                SpendPlan::new(&mut OsRng, note, auth_path.position()).into(),
204                SpendPlan::new(&mut OsRng, note2, auth_path2.position()).into(),
205                OutputPlan::new(&mut OsRng, value, test_keys::ADDRESS_1.deref().clone()).into(),
206            ],
207            detection_data: Some(DetectionDataPlan {
208                clue_plans: vec![CluePlan::new(
209                    &mut OsRng,
210                    test_keys::ADDRESS_1.deref().clone(),
211                    1.try_into().unwrap(),
212                )],
213            }),
214            memo: None,
215        };
216
217        // Build the transaction.
218        let fvk = &test_keys::FULL_VIEWING_KEY;
219        let sk = &test_keys::SPEND_KEY;
220        let auth_data = plan.authorize(OsRng, sk)?;
221        let witness_data = WitnessData {
222            anchor: sct.root(),
223            state_commitment_proofs: plan
224                .spend_plans()
225                .map(|spend| {
226                    (
227                        spend.note.commit(),
228                        sct.witness(spend.note.commit()).unwrap(),
229                    )
230                })
231                .collect(),
232        };
233        let tx = plan
234            .build_concurrent(fvk, &witness_data, &auth_data)
235            .await
236            .expect("can build transaction");
237
238        let context = tx.context();
239
240        // On the verifier side, perform stateless verification.
241        for action in tx.transaction_body().actions {
242            let result = action.check_stateless(context.clone()).await;
243            assert!(result.is_ok())
244        }
245
246        Ok(())
247    }
248
249    #[tokio::test]
250    async fn check_stateless_fails_on_auth_path_with_wrong_root() -> Result<()> {
251        // Generate a note controlled by the test address.
252        let value = Value {
253            amount: 100u64.into(),
254            asset_id: *STAKING_TOKEN_ASSET_ID,
255        };
256        let note = Note::generate(&mut OsRng, &test_keys::ADDRESS_0, value);
257
258        // Record that note in an SCT, where we can generate an auth path.
259        let mut sct = tct::Tree::new();
260        let wrong_root = sct.root();
261        sct.insert(tct::Witness::Keep, note.commit()).unwrap();
262        let auth_path = sct.witness(note.commit()).unwrap();
263
264        // Add a single spend and output to the transaction plan such that the
265        // transaction balances.
266        let plan = TransactionPlan {
267            transaction_parameters: TransactionParameters {
268                expiry_height: 0,
269                fee: Fee::default(),
270                chain_id: "".into(),
271            },
272            actions: vec![
273                SpendPlan::new(&mut OsRng, note, auth_path.position()).into(),
274                OutputPlan::new(&mut OsRng, value, test_keys::ADDRESS_1.deref().clone()).into(),
275            ],
276            detection_data: None,
277            memo: None,
278        };
279
280        // Build the transaction.
281        let fvk = &test_keys::FULL_VIEWING_KEY;
282        let sk = &test_keys::SPEND_KEY;
283        let auth_data = plan.authorize(OsRng, sk)?;
284        let witness_data = WitnessData {
285            anchor: sct.root(),
286            state_commitment_proofs: plan
287                .spend_plans()
288                .map(|spend| {
289                    (
290                        spend.note.commit(),
291                        sct.witness(spend.note.commit()).unwrap(),
292                    )
293                })
294                .collect(),
295        };
296        let mut tx = plan
297            .build_concurrent(fvk, &witness_data, &auth_data)
298            .await
299            .expect("can build transaction");
300
301        // Set the anchor to the wrong root.
302        tx.anchor = wrong_root;
303
304        // On the verifier side, perform stateless verification.
305        let result = tx.check_stateless(()).await;
306        assert!(result.is_err());
307
308        Ok(())
309    }
310}