monero_wallet/
scan.rs

1use core::ops::Deref as _;
2use std_shims::{vec, vec::Vec, collections::HashMap};
3
4use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
5
6#[cfg(feature = "compile-time-generators")]
7use curve25519_dalek::constants::ED25519_BASEPOINT_TABLE;
8#[cfg(not(feature = "compile-time-generators"))]
9use curve25519_dalek::constants::ED25519_BASEPOINT_POINT as ED25519_BASEPOINT_TABLE;
10
11use monero_oxide::{
12  ed25519::{Scalar, CompressedPoint, Point, Commitment},
13  transaction::{Timelock, Pruned, Transaction},
14};
15use monero_interface::ScannableBlock;
16use crate::{
17  address::SubaddressIndex, ViewPair, GuaranteedViewPair, output::*, PaymentId, Extra,
18  SharedKeyDerivations,
19};
20
21/// A collection of potentially additionally timelocked outputs.
22#[derive(Zeroize, ZeroizeOnDrop)]
23pub struct Timelocked(Vec<WalletOutput>);
24
25impl Timelocked {
26  /// Return the outputs which aren't subject to an additional timelock.
27  #[must_use]
28  pub fn not_additionally_locked(self) -> Vec<WalletOutput> {
29    let mut res = vec![];
30    for output in &self.0 {
31      if output.additional_timelock() == Timelock::None {
32        res.push(output.clone());
33      }
34    }
35    res
36  }
37
38  /// Return the outputs whose additional timelock unlocks by the specified block/time.
39  ///
40  /// Additional timelocks are almost never used outside of miner transactions, and are
41  /// increasingly planned for removal. Ignoring non-miner additionally-timelocked outputs is
42  /// recommended.
43  ///
44  /// `block` is the block number of the block the additional timelock must be satsified by.
45  ///
46  /// `time` is represented in seconds since the epoch and is in terms of Monero's on-chain clock.
47  /// That means outputs whose additional timelocks are statisfied by `Instant::now()` (the time
48  /// according to the local system clock) may still be locked due to variance with Monero's clock.
49  #[must_use]
50  pub fn additional_timelock_satisfied_by(self, block: usize, time: u64) -> Vec<WalletOutput> {
51    let mut res = vec![];
52    for output in &self.0 {
53      if (output.additional_timelock() <= Timelock::Block(block)) ||
54        (output.additional_timelock() <= Timelock::Time(time))
55      {
56        res.push(output.clone());
57      }
58    }
59    res
60  }
61
62  /// Ignore the timelocks and return all outputs within this container.
63  #[must_use]
64  pub fn ignore_additional_timelock(mut self) -> Vec<WalletOutput> {
65    let mut res = vec![];
66    core::mem::swap(&mut self.0, &mut res);
67    res
68  }
69}
70
71/// Errors when scanning a block.
72#[derive(Clone, Copy, PartialEq, Eq, Debug, thiserror::Error)]
73pub enum ScanError {
74  /// The block was for an unsupported protocol version.
75  #[error("unsupported protocol version ({0})")]
76  UnsupportedProtocol(u8),
77  /// The ScannableBlock was invalid.
78  #[error("invalid scannable block ({0})")]
79  InvalidScannableBlock(&'static str),
80}
81
82#[derive(Clone)]
83struct InternalScanner {
84  pair: ViewPair,
85  guaranteed: bool,
86  subaddresses: HashMap<CompressedPoint, Option<SubaddressIndex>>,
87}
88
89impl Zeroize for InternalScanner {
90  #[expect(clippy::iter_over_hash_type)]
91  fn zeroize(&mut self) {
92    self.pair.zeroize();
93    self.guaranteed.zeroize();
94
95    // This may not be effective, unfortunately
96    for (mut key, mut value) in self.subaddresses.drain() {
97      key.zeroize();
98      value.zeroize();
99    }
100  }
101}
102impl Drop for InternalScanner {
103  fn drop(&mut self) {
104    self.zeroize();
105  }
106}
107impl ZeroizeOnDrop for InternalScanner {}
108
109impl InternalScanner {
110  fn new(pair: ViewPair, guaranteed: bool) -> Self {
111    let mut subaddresses = HashMap::new();
112    subaddresses.insert(pair.spend().compress(), None);
113    Self { pair, guaranteed, subaddresses }
114  }
115
116  fn register_subaddress(&mut self, subaddress: SubaddressIndex) {
117    let (spend, _) = self.pair.subaddress_keys(subaddress);
118    self.subaddresses.insert(spend.compress(), Some(subaddress));
119  }
120
121  fn scan_transaction(
122    &self,
123    output_index_for_first_ringct_output: u64,
124    tx_hash: [u8; 32],
125    tx: &Transaction<Pruned>,
126  ) -> Result<Timelocked, ScanError> {
127    // Only scan TXs creating RingCT outputs
128    // For the full details on why this check is equivalent, please see the documentation in `scan`
129    if tx.version() != 2 {
130      return Ok(Timelocked(vec![]));
131    }
132
133    // Read the extra field
134    let Ok(extra) = Extra::read(&mut tx.prefix().extra.as_slice()) else {
135      return Ok(Timelocked(vec![]));
136    };
137
138    let Some((tx_keys, additional)) = extra.keys() else {
139      return Ok(Timelocked(vec![]));
140    };
141    let payment_id = extra.payment_id();
142
143    let mut res = vec![];
144    for (o, output) in tx.prefix().outputs.iter().enumerate() {
145      /*
146        Explicitly skip keys which are the identity.
147
148        These are theoretically able to be scanned (with negligible probability except for a
149        recipient who chooses their keys as to cause this), but are unspendable due to restrictions
150        the key image isn't the identity point (in place since the RingCT upgrade).
151      */
152      if output.key == CompressedPoint::IDENTITY {
153        continue;
154      }
155
156      let Some(output_key) = output.key.decompress() else { continue };
157
158      // Monero checks with each TX key and with the additional key for this output
159
160      // This will be None if there's no additional keys, Some(None) if there's additional keys
161      // yet not one for this output (which is non-standard), and Some(Some(_)) if there's an
162      // additional key for this output
163      // https://github.com/monero-project/monero/blob/cc73fe71162d564ffda8e549b79a350bca53c454
164      //   /src/cryptonote_basic/cryptonote_format_utils.cpp#L1060-L1070
165      let additional = additional.as_ref().and_then(|additional| additional.get(o));
166
167      for key in tx_keys.iter().map(Some).chain(core::iter::once(additional)).flatten().copied() {
168        // Calculate the ECDH
169        let ecdh = {
170          let dalek_view = Zeroizing::new((*self.pair.view).into());
171          Zeroizing::new(Point::from(dalek_view.deref() * key.into()))
172        };
173        let output_derivations = SharedKeyDerivations::output_derivations(
174          self.guaranteed.then(|| SharedKeyDerivations::uniqueness(&tx.prefix().inputs)),
175          ecdh.clone(),
176          o,
177        );
178
179        // Check the view tag matches, if there is a view tag
180        if let Some(actual_view_tag) = output.view_tag {
181          if actual_view_tag != output_derivations.view_tag {
182            continue;
183          }
184        }
185
186        // P - shared == spend
187        let Some(subaddress) = ({
188          // The output key may be of torsion [0, 8)
189          // Our subtracting of a prime-order element means any torsion will be preserved
190          // If someone wanted to malleate output keys with distinct torsions, only one will be
191          // scanned accordingly (the one which has matching torsion of the spend key)
192          let subaddress_spend_key =
193            output_key.into() - (&output_derivations.shared_key.into() * ED25519_BASEPOINT_TABLE);
194          self
195            .subaddresses
196            .get::<CompressedPoint>(&subaddress_spend_key.compress().to_bytes().into())
197        }) else {
198          continue;
199        };
200        let subaddress = *subaddress;
201
202        // The key offset is this shared key
203        let mut key_offset = output_derivations.shared_key.into();
204        if let Some(subaddress) = subaddress {
205          // And if this was to a subaddress, it's additionally the offset from subaddress spend
206          // key to the normal spend key
207          key_offset += self.pair.subaddress_derivation(subaddress).into();
208        }
209        // Since we've found an output to us, get its amount
210        let mut commitment = Commitment::zero();
211
212        // Miner transaction
213        if let Some(amount) = output.amount {
214          commitment.amount = amount;
215        // Regular transaction
216        } else {
217          let Transaction::V2 { proofs: Some(ref proofs), .. } = &tx else {
218            // Invalid transaction, as of consensus rules at the time of writing this code
219            Err(ScanError::InvalidScannableBlock("non-miner v2 transaction without RCT proofs"))?
220          };
221
222          commitment = match proofs.base.encrypted_amounts.get(o) {
223            Some(amount) => output_derivations.decrypt(amount),
224            // Invalid transaction, as of consensus rules at the time of writing this code
225            None => Err(ScanError::InvalidScannableBlock(
226              "RCT proofs without an encrypted amount per output",
227            ))?,
228          };
229
230          // Rebuild the commitment to verify it
231          if Some(&commitment.commit().compress()) != proofs.base.commitments.get(o) {
232            continue;
233          }
234        }
235
236        // Decrypt the payment ID
237        let payment_id = payment_id.map(|id| id ^ SharedKeyDerivations::payment_id_xor(ecdh));
238
239        let o = u64::try_from(o).expect("couldn't convert output index (usize) to u64");
240
241        res.push(WalletOutput {
242          absolute_id: AbsoluteId { transaction: tx_hash, index_in_transaction: o },
243          relative_id: RelativeId {
244            index_on_blockchain: output_index_for_first_ringct_output.checked_add(o).ok_or(
245              ScanError::InvalidScannableBlock(
246                "transaction's output's index isn't representable as a u64",
247              ),
248            )?,
249          },
250          data: OutputData { key: output_key, key_offset: Scalar::from(key_offset), commitment },
251          metadata: Metadata {
252            additional_timelock: tx.prefix().additional_timelock,
253            subaddress,
254            payment_id,
255            arbitrary_data: extra.arbitrary_data(),
256          },
257        });
258
259        // Break to prevent public keys from being included multiple times, triggering multiple
260        // inclusions of the same output
261        break;
262      }
263    }
264
265    Ok(Timelocked(res))
266  }
267
268  fn scan(&mut self, block: ScannableBlock) -> Result<Timelocked, ScanError> {
269    // This is the output index for the first RingCT output within the block
270    // We mutate it to be the output index for the first RingCT for each transaction
271    let ScannableBlock { block, transactions, output_index_for_first_ringct_output } = block;
272    if block.transactions.len() != transactions.len() {
273      Err(ScanError::InvalidScannableBlock(
274        "scanning a ScannableBlock with more/less transactions than it should have",
275      ))?;
276    }
277    let Some(mut output_index_for_first_ringct_output) = output_index_for_first_ringct_output
278    else {
279      return Ok(Timelocked(vec![]));
280    };
281
282    if block.header.hardfork_version > 16 {
283      Err(ScanError::UnsupportedProtocol(block.header.hardfork_version))?;
284    }
285
286    // We obtain all TXs in full
287    let mut txs_with_hashes = vec![(
288      block.miner_transaction().hash(),
289      Transaction::<Pruned>::from(block.miner_transaction().clone()),
290    )];
291    for (hash, tx) in block.transactions.iter().zip(transactions) {
292      txs_with_hashes.push((*hash, tx));
293    }
294
295    let mut res = Timelocked(vec![]);
296    for (hash, tx) in txs_with_hashes {
297      // Push all outputs into our result
298      {
299        let mut this_txs_outputs = vec![];
300        core::mem::swap(
301          &mut self.scan_transaction(output_index_for_first_ringct_output, hash, &tx)?.0,
302          &mut this_txs_outputs,
303        );
304        res.0.extend(this_txs_outputs);
305      }
306
307      // Update the RingCT starting index for the next TX
308      if matches!(tx, Transaction::V2 { .. }) {
309        output_index_for_first_ringct_output = output_index_for_first_ringct_output
310          .checked_add(
311            u64::try_from(tx.prefix().outputs.len())
312              .expect("couldn't convert amount of outputs (usize) to u64"),
313          )
314          .ok_or(ScanError::InvalidScannableBlock("RingCT output indexes exceeded u64::MAX"))?;
315      }
316    }
317
318    // If the block's version is >= 12, drop all unencrypted payment IDs
319    // https://github.com/monero-project/monero/blob/ac02af92867590ca80b2779a7bbeafa99ff94dcb/
320    //   src/wallet/wallet2.cpp#L2739-L2744
321    if block.header.hardfork_version >= 12 {
322      for output in &mut res.0 {
323        if matches!(output.metadata.payment_id, Some(PaymentId::Unencrypted(_))) {
324          output.metadata.payment_id = None;
325        }
326      }
327    }
328
329    Ok(res)
330  }
331}
332
333/// A transaction scanner to find outputs received.
334///
335/// When an output is successfully scanned, the output key MUST be checked against the local
336/// database for lack of prior observation. If it was prior observed, that output is an instance
337/// of the
338/// [burning bug](https://web.getmonero.org/2018/09/25/a-post-mortum-of-the-burning-bug.html) and
339/// MAY be unspendable. Only the prior received output(s) or the newly received output will be
340/// spendable (as spending one will burn all of them).
341///
342/// Once checked, the output key MUST be saved to the local database so future checks can be
343/// performed.
344#[derive(Clone, Zeroize, ZeroizeOnDrop)]
345pub struct Scanner(InternalScanner);
346
347impl Scanner {
348  /// Create a Scanner from a ViewPair.
349  pub fn new(pair: ViewPair) -> Self {
350    Self(InternalScanner::new(pair, false))
351  }
352
353  /// Register a subaddress to scan for.
354  ///
355  /// Subaddresses must be explicitly registered ahead of time in order to be successfully scanned.
356  ///
357  /// This function runs in variable time, notably with regards to the distribution of subaddress
358  /// derivations (which should be reasonably uniform) and the amount of subaddresses registered.
359  pub fn register_subaddress(&mut self, subaddress: SubaddressIndex) {
360    self.0.register_subaddress(subaddress);
361  }
362
363  /// Scan a block.
364  ///
365  /// This function runs in variable time, notably with regards to how the private view key relates
366  /// to outputs present within the block (such as if it can successfully scan outputs present).
367  pub fn scan(&mut self, block: ScannableBlock) -> Result<Timelocked, ScanError> {
368    self.0.scan(block)
369  }
370}
371
372/// A transaction scanner to find outputs received which are guaranteed to be spendable.
373///
374/// 'Guaranteed' outputs, or transactions outputs to the burning bug, are not officially specified
375/// by the Monero project. They should only be used if necessary. No support outside of
376/// monero-wallet is promised.
377///
378/// "guaranteed to be spendable" assumes satisfaction of any timelocks in effect.
379#[derive(Clone, Zeroize, ZeroizeOnDrop)]
380pub struct GuaranteedScanner(InternalScanner);
381
382impl GuaranteedScanner {
383  /// Create a GuaranteedScanner from a GuaranteedViewPair.
384  pub fn new(pair: GuaranteedViewPair) -> Self {
385    Self(InternalScanner::new(pair.0, true))
386  }
387
388  /// Register a subaddress to scan for.
389  ///
390  /// Subaddresses must be explicitly registered ahead of time in order to be successfully scanned.
391  ///
392  /// This function runs in variable time, notably with regards to the distribution of subaddress
393  /// derivations (which should be reasonably uniform) and the amount of subaddresses registered.
394  pub fn register_subaddress(&mut self, subaddress: SubaddressIndex) {
395    self.0.register_subaddress(subaddress);
396  }
397
398  /// Scan a block.
399  ///
400  /// This function runs in variable time, notably with regards to how the private view key relates
401  /// to outputs present within the block (such as if it can successfully scan outputs present).
402  pub fn scan(&mut self, block: ScannableBlock) -> Result<Timelocked, ScanError> {
403    self.0.scan(block)
404  }
405}