penumbra_sdk_view/
sync.rs

1use std::collections::BTreeMap;
2
3use penumbra_sdk_compact_block::{CompactBlock, StatePayload};
4use penumbra_sdk_dex::swap::{SwapPayload, SwapPlaintext};
5use penumbra_sdk_fee::GasPrices;
6use penumbra_sdk_keys::FullViewingKey;
7use penumbra_sdk_sct::Nullifier;
8use penumbra_sdk_shielded_pool::{fmd, Note, NotePayload};
9use penumbra_sdk_tct::{self as tct, StateCommitment};
10use tracing::Instrument;
11
12use crate::{SpendableNoteRecord, Storage, SwapRecord};
13
14/// Contains the results of scanning a single block.
15#[derive(Debug, Clone)]
16pub struct FilteredBlock {
17    pub new_notes: BTreeMap<StateCommitment, SpendableNoteRecord>,
18    pub new_swaps: BTreeMap<StateCommitment, SwapRecord>,
19    pub spent_nullifiers: Vec<Nullifier>,
20    pub height: u64,
21    pub fmd_parameters: Option<fmd::Parameters>,
22    pub app_parameters_updated: bool,
23    pub gas_prices: Option<GasPrices>,
24}
25
26#[tracing::instrument(skip_all, fields(height = %height))]
27pub async fn scan_block(
28    fvk: &FullViewingKey,
29    state_commitment_tree: &mut tct::Tree,
30    CompactBlock {
31        height,
32        state_payloads,
33        nullifiers,
34        block_root,
35        epoch_root,
36        fmd_parameters,
37        swap_outputs,
38        app_parameters_updated,
39        gas_prices,
40        // TODO: do we need this, or is there a bug in scan_block?
41        // proposal_started,
42        ..
43    }: CompactBlock,
44    storage: &Storage,
45) -> anyhow::Result<FilteredBlock> {
46    // Trial-decrypt a note with our own specific viewing key
47    let trial_decrypt_note = |note_payload: NotePayload| -> tokio::task::JoinHandle<Option<Note>> {
48        // TODO: change fvk to Arc<FVK> in Worker and pass to scan_block as Arc
49        // need this so the task is 'static and not dependent on key lifetime
50        let fvk2 = fvk.clone();
51        tokio::spawn(
52            async move { note_payload.trial_decrypt(&fvk2) }.instrument(tracing::Span::current()),
53        )
54    };
55    // Trial-decrypt a swap with our own specific viewing key
56    let trial_decrypt_swap =
57        |swap_payload: SwapPayload| -> tokio::task::JoinHandle<Option<SwapPlaintext>> {
58            // TODO: change fvk to Arc<FVK> in Worker and pass to scan_block as Arc
59            // need this so the task is 'static and not dependent on key lifetime
60            let fvk2 = fvk.clone();
61            tokio::spawn(
62                async move { swap_payload.trial_decrypt(&fvk2) }
63                    .instrument(tracing::Span::current()),
64            )
65        };
66
67    // Nullifiers we've found in this block
68    let spent_nullifiers: Vec<Nullifier> = nullifiers;
69
70    // Trial-decrypt the notes in this block, keeping track of the ones that were meant for us
71    let mut note_decryptions = Vec::new();
72    let mut swap_decryptions = Vec::new();
73    let mut unknown_commitments = Vec::new();
74
75    for payload in state_payloads.iter() {
76        match payload {
77            StatePayload::Note { note, .. } => {
78                note_decryptions.push(trial_decrypt_note((**note).clone()));
79            }
80            StatePayload::Swap { swap, .. } => {
81                swap_decryptions.push(trial_decrypt_swap((**swap).clone()));
82            }
83            StatePayload::RolledUp { commitment, .. } => unknown_commitments.push(*commitment),
84        }
85    }
86    // Having started trial decryption in the background, ask the Storage for scanning advice:
87    let mut note_advice = storage.scan_advice(unknown_commitments).await?;
88    for decryption in note_decryptions {
89        if let Some(note) = decryption
90            .await
91            .expect("able to join tokio note decryption handle")
92        {
93            note_advice.insert(note.commit(), note);
94        }
95    }
96    let mut swap_advice = BTreeMap::new();
97    for decryption in swap_decryptions {
98        if let Some(swap) = decryption
99            .await
100            .expect("able to join tokio swap decryption handle")
101        {
102            swap_advice.insert(swap.swap_commitment(), swap);
103        }
104    }
105
106    // Newly detected spendable notes.
107    let mut new_notes = BTreeMap::new();
108    // Newly detected claimable swaps.
109    let mut new_swaps = BTreeMap::new();
110
111    if note_advice.is_empty() && swap_advice.is_empty() {
112        // If there are no notes we care about in this block, just insert the block root into the
113        // tree instead of processing each commitment individually
114        state_commitment_tree
115            .insert_block(block_root)
116            .expect("inserting a block root must succeed");
117    } else {
118        // If we found at least one note for us in this block, we have to explicitly construct the
119        // whole block in the SCT by inserting each commitment one at a time
120        tracing::debug!("found at least one relevant SCT entry, reconstructing block subtree");
121
122        for payload in state_payloads.into_iter() {
123            // We need to insert each commitment, so use a match statement to ensure we
124            // exhaustively cover all possible cases.
125            match (
126                note_advice.get(payload.commitment()),
127                swap_advice.get(payload.commitment()),
128            ) {
129                (Some(note), None) => {
130                    // Keep track of this commitment for later witnessing
131                    let position = state_commitment_tree
132                        .insert(tct::Witness::Keep, *payload.commitment())
133                        .expect("inserting a commitment must succeed");
134
135                    let source = payload.source().clone();
136                    let nullifier =
137                        Nullifier::derive(fvk.nullifier_key(), position, payload.commitment());
138                    let address_index = fvk.incoming().index_for_diversifier(note.diversifier());
139
140                    new_notes.insert(
141                        *payload.commitment(),
142                        SpendableNoteRecord {
143                            note_commitment: *payload.commitment(),
144                            height_spent: None,
145                            height_created: height,
146                            note: note.clone(),
147                            address_index,
148                            nullifier,
149                            position,
150                            source,
151                            return_address: None,
152                        },
153                    );
154                }
155                (None, Some(swap)) => {
156                    // Keep track of this commitment for later witnessing
157                    let position = state_commitment_tree
158                        .insert(tct::Witness::Keep, *payload.commitment())
159                        .expect("inserting a commitment must succeed");
160
161                    let Some(output_data) = swap_outputs.get(&swap.trading_pair).cloned() else {
162                        // We've been given an invalid compact block, but we
163                        // should keep going, because the fullnode we're talking
164                        // to could be lying to us and handing us crafted blocks
165                        // with garbage data only we can see, in order to
166                        // pinpoint whether or not we control a specific address,
167                        // so we can't let on that we've noticed any problem.
168                        tracing::warn!("invalid compact block, batch swap output data missing for trading pair {:?}", swap.trading_pair);
169                        continue;
170                    };
171
172                    // Record the output notes for the future swap claim, so we can detect
173                    // them when the swap is claimed.
174                    let (output_1, output_2) = swap.output_notes(&output_data);
175                    storage.give_advice(output_1).await?;
176                    storage.give_advice(output_2).await?;
177
178                    let source = payload.source().clone();
179                    let nullifier =
180                        Nullifier::derive(fvk.nullifier_key(), position, payload.commitment());
181
182                    new_swaps.insert(
183                        *payload.commitment(),
184                        SwapRecord {
185                            swap_commitment: *payload.commitment(),
186                            swap: swap.clone(),
187                            position,
188                            nullifier,
189                            source,
190                            output_data,
191                            height_claimed: None,
192                        },
193                    );
194                }
195                (None, None) => {
196                    // Don't remember this commitment; it wasn't ours
197                    state_commitment_tree
198                        .insert(tct::Witness::Forget, *payload.commitment())
199                        .expect("inserting a commitment must succeed");
200                }
201                (Some(_), Some(_)) => unreachable!("swap and note commitments are distinct"),
202            }
203        }
204
205        // End the block in the commitment tree
206        state_commitment_tree
207            .end_block()
208            .expect("ending the block must succeed");
209    }
210
211    // If we've also reached the end of the epoch, end the epoch in the commitment tree
212    let is_epoch_end = epoch_root.is_some();
213    if is_epoch_end {
214        tracing::debug!(?height, "end of epoch");
215        state_commitment_tree
216            .end_epoch()
217            .expect("ending the epoch must succeed");
218    }
219
220    // Print the TCT root for debugging
221    #[cfg(feature = "sct-divergence-check")]
222    tracing::debug!(tct_root = %state_commitment_tree.root(), "tct root");
223
224    // Filter nullifiers to remove any without matching note note_commitments
225    // This is a very important optimization to avoid unnecessary query load on the storage backend
226    // -- it results in 100x+ slower sync times if we don't do this!
227    let filtered_nullifiers = storage.filter_nullifiers(spent_nullifiers).await?;
228
229    // Construct filtered block
230    let result = FilteredBlock {
231        new_notes,
232        new_swaps,
233        spent_nullifiers: filtered_nullifiers,
234        height,
235        fmd_parameters,
236        app_parameters_updated,
237        gas_prices,
238    };
239
240    Ok(result)
241}