monero_wallet/
scan.rs

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