monero_wallet/
decoys.rs

1#![expect(clippy::as_conversions, clippy::float_arithmetic)]
2
3use std_shims::{prelude::*, io, vec::Vec, collections::HashSet};
4
5use zeroize::{Zeroize, ZeroizeOnDrop};
6
7use rand_core::{RngCore, CryptoRng};
8use rand_distr::{Distribution as _, Gamma};
9#[cfg(not(feature = "std"))]
10use rand_distr::num_traits::Float;
11
12use crate::{
13  DEFAULT_LOCK_WINDOW, COINBASE_LOCK_WINDOW, BLOCK_TIME,
14  ed25519::{Scalar, Point, Commitment},
15  ringct::clsag::Decoys,
16  interface::{InterfaceError, TransactionsError, EvaluateUnlocked, ProvidesDecoys},
17  output::OutputData,
18  WalletOutput,
19};
20
21const RECENT_WINDOW: u64 = 15;
22const BLOCKS_PER_YEAR: usize = (365 * 24 * 60 * 60) / BLOCK_TIME;
23#[expect(clippy::cast_precision_loss)]
24const TIP_APPLICATION: f64 = (DEFAULT_LOCK_WINDOW * BLOCK_TIME) as f64;
25
26async fn select_n(
27  rng: &mut (impl RngCore + CryptoRng),
28  rpc: &impl ProvidesDecoys,
29  block_number: usize,
30  output_being_spent: &WalletOutput,
31  ring_len: u8,
32  fingerprintable_deterministic: bool,
33) -> Result<Vec<(u64, [Point; 2])>, TransactionsError> {
34  if block_number <= DEFAULT_LOCK_WINDOW {
35    Err(InterfaceError::InternalError("not enough blocks to select decoys".to_owned()))?;
36  }
37  if block_number > rpc.latest_block_number().await? {
38    Err(InterfaceError::InternalError(
39      "decoys being requested from blocks this node doesn't have".to_owned(),
40    ))?;
41  }
42
43  // Get the distribution
44  let distribution = rpc.ringct_output_distribution(..= block_number).await?;
45  if distribution.len() < DEFAULT_LOCK_WINDOW {
46    Err(InterfaceError::InternalError("not enough blocks to select decoys".to_owned()))?;
47  }
48  let highest_output_exclusive_bound = distribution[distribution.len() - DEFAULT_LOCK_WINDOW];
49  // This assumes that each miner TX had one output (as sane) and checks we have sufficient
50  // outputs even when excluding them (due to their own timelock requirements)
51  // Considering this a temporal error for very new chains, it's sufficiently sane to have
52  if highest_output_exclusive_bound.saturating_sub(
53    u64::try_from(COINBASE_LOCK_WINDOW).expect("coinbase lock window exceeds 2^{64}"),
54  ) < u64::from(ring_len)
55  {
56    Err(InterfaceError::InternalError("not enough decoy candidates".to_owned()))?;
57  }
58
59  // Determine the outputs per second
60  #[expect(clippy::cast_precision_loss)]
61  let per_second = {
62    let blocks = distribution.len().min(BLOCKS_PER_YEAR);
63    let initial = distribution[distribution.len().saturating_sub(blocks + 1)];
64    let outputs = distribution[distribution.len() - 1].saturating_sub(initial);
65    (outputs as f64) / ((blocks * BLOCK_TIME) as f64)
66  };
67
68  let output_being_spent_index = output_being_spent.relative_id.index_on_blockchain;
69
70  // Don't select the real output
71  let mut do_not_select = HashSet::new();
72  do_not_select.insert(output_being_spent_index);
73
74  let decoy_count = usize::from(ring_len - 1);
75  let mut res = Vec::with_capacity(decoy_count);
76
77  let mut first_iter = true;
78  let mut iters = 0;
79  // Iterates until we have enough decoys
80  // If an iteration only returns a partial set of decoys, the remainder will be obvious as decoys
81  // to the RPC
82  // The length of that remainder is expected to be minimal
83  while res.len() != decoy_count {
84    {
85      iters += 1;
86      const MAX_ITERS: usize = {
87        #[cfg_attr(test, expect(unused))]
88        let max_iters = 10;
89        // When testing on fresh chains, increased iterations can be useful and we don't
90        // necessitate reasonable performance
91        #[cfg(test)]
92        let max_iters = 1000;
93        max_iters
94      };
95      // Ensure this isn't infinitely looping
96      // We check both that we aren't at the maximum amount of iterations and that the not-yet
97      // selected candidates exceed the amount of candidates necessary to trigger the next iteration
98      if (iters == MAX_ITERS) ||
99        ((highest_output_exclusive_bound -
100          u64::try_from(do_not_select.len())
101            .expect("amount of ignored decoys exceeds 2^{64}")) <
102          u64::from(ring_len))
103      {
104        Err(InterfaceError::InternalError("hit decoy selection round limit".to_owned()))?;
105      }
106    }
107
108    let remaining = decoy_count - res.len();
109    let mut candidates = Vec::with_capacity(remaining);
110    while candidates.len() != remaining {
111      // Use a gamma distribution, as Monero does
112      // https://github.com/monero-project/monero/blob/cc73fe71162d564ffda8e549b79a350bca53c45
113      //   /src/wallet/wallet2.cpp#L142-L143
114      let mut age = Gamma::<f64>::new(19.28, 1.0 / 1.61)
115        .expect("constant Gamma distribution could no longer be created")
116        .sample(rng)
117        .exp();
118      #[expect(clippy::cast_precision_loss)]
119      if age > TIP_APPLICATION {
120        age -= TIP_APPLICATION;
121      } else {
122        // f64 does not have try_from available, which is why these are written with `as`
123        age = (rng.next_u64() %
124          (RECENT_WINDOW * u64::try_from(BLOCK_TIME).expect("BLOCK_TIME exceeded u64::MAX")))
125          as f64;
126      }
127
128      #[expect(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
129      let o = (age * per_second) as u64;
130      if o < highest_output_exclusive_bound {
131        // Find which block this points to
132        let i = distribution.partition_point(|s| *s < (highest_output_exclusive_bound - 1 - o));
133        let prev = i.saturating_sub(1);
134        let n = distribution[i].checked_sub(distribution[prev]).ok_or_else(|| {
135          InterfaceError::InternalError("RPC returned non-monotonic distribution".to_owned())
136        })?;
137        if n != 0 {
138          // Select an output from within this block
139          let o = distribution[prev] + (rng.next_u64() % n);
140          if !do_not_select.contains(&o) {
141            candidates.push(o);
142            // This output will either be used or is unusable
143            // In either case, we should not try it again
144            do_not_select.insert(o);
145          }
146        }
147      }
148    }
149
150    // If this is the first time we're requesting these outputs, include the real one as well
151    // Prevents the node we're connected to from having a list of known decoys and then seeing a
152    // TX which uses all of them, with one additional output (the true spend)
153    let real_index = first_iter.then(|| {
154      first_iter = false;
155
156      candidates.push(output_being_spent_index);
157      // Sort candidates so the real spends aren't the ones at the end
158      candidates.sort_unstable();
159      candidates
160        .binary_search(&output_being_spent_index)
161        .expect("selected a ring which didn't include the real spend")
162    });
163
164    for (i, output) in rpc
165      .unlocked_ringct_outputs(
166        &candidates,
167        if fingerprintable_deterministic {
168          EvaluateUnlocked::FingerprintableDeterministic { block_number }
169        } else {
170          EvaluateUnlocked::Normal
171        },
172      )
173      .await?
174      .iter_mut()
175      .enumerate()
176    {
177      // https://github.com/monero-oxide/monero-oxide/issues/56
178      if real_index == Some(i) {
179        if (Some(output_being_spent.key()) != output.map(|[key, _commitment]| key)) ||
180          (Some(output_being_spent.commitment().commit()) !=
181            output.map(|[_key, commitment]| commitment))
182        {
183          Err(InterfaceError::InvalidInterface(
184            "node presented different view of output we're trying to spend".to_owned(),
185          ))?;
186        }
187
188        continue;
189      }
190
191      // If this is an unlocked output, push it to the result
192      if let Some(output) = output.take() {
193        {
194          let [key, commitment] = output;
195          // Unless torsion is present
196          // https://github.com/monero-project/monero/blob/893916ad091a92e765ce3241b94e706ad012b62a
197          //   /src/wallet/wallet2.cpp#L9050-L9060
198          if !(key.into().is_torsion_free() && commitment.into().is_torsion_free()) {
199            continue;
200          }
201          /*
202            Or the key is the identity, and accordingly cannot be signed for as a real ring member.
203
204            If Monero omits this check, then transactions may technically be fingerprinted as from
205            `wallet2` (and not `monero-wallet`). We accept this (arguable) fingerprint.
206          */
207          use curve25519_dalek::traits::IsIdentity as _;
208          if key.into().is_identity() {
209            continue;
210          }
211        }
212        res.push((candidates[i], output));
213      }
214    }
215  }
216
217  Ok(res)
218}
219
220async fn select_decoys<R: RngCore + CryptoRng>(
221  rng: &mut R,
222  rpc: &impl ProvidesDecoys,
223  ring_len: u8,
224  block_number: usize,
225  input: &WalletOutput,
226  fingerprintable_deterministic: bool,
227) -> Result<Decoys, TransactionsError> {
228  if ring_len == 0 {
229    Err(InterfaceError::InternalError("requesting a ring of length 0".to_owned()))?;
230  }
231
232  // Select all decoys for this transaction, assuming we generate a sane transaction
233  // We should almost never naturally generate an insane transaction, hence why this doesn't
234  // bother with an overage
235  let decoys =
236    select_n(rng, rpc, block_number, input, ring_len, fingerprintable_deterministic).await?;
237
238  // Form the complete ring
239  let mut ring = decoys;
240  ring.push((input.relative_id.index_on_blockchain, [input.key(), input.commitment().commit()]));
241  ring.sort_by(|a, b| a.0.cmp(&b.0));
242
243  /*
244    Monero does have sanity checks which it applies to the selected ring.
245
246    They're statistically unlikely to be hit and only occur when the transaction is published over
247    the RPC (so they are not a relay rule). The RPC allows disabling them, which our RPC
248    implementations do to ensure they don't pose a problem.
249
250    They aren't worth the complexity to implement here, especially since they're non-deterministic.
251  */
252
253  // We need to convert our positional indexes to offset indexes
254  let mut offsets = Vec::with_capacity(ring.len());
255  {
256    offsets.push(ring[0].0);
257    for m in 1 .. ring.len() {
258      offsets.push(ring[m].0 - ring[m - 1].0);
259    }
260  }
261
262  Ok(
263    Decoys::new(
264      offsets,
265      // Binary searches for the real spend since we don't know where it sorted to
266      u8::try_from(ring.partition_point(|x| x.0 < input.relative_id.index_on_blockchain))
267        .expect("ring of size <= u8::MAX had an index exceeding u8::MAX"),
268      ring.into_iter().map(|output| output.1).collect(),
269    )
270    .expect("selected a syntactically-invalid set of Decoys"),
271  )
272}
273
274/// An output with decoys selected.
275///
276/// The `Debug` implementation may reveal every value within its memory.
277#[derive(Clone, Debug, Zeroize, ZeroizeOnDrop)]
278pub struct OutputWithDecoys {
279  output: OutputData,
280  decoys: Decoys,
281}
282
283impl PartialEq for OutputWithDecoys {
284  fn eq(&self, other: &Self) -> bool {
285    bool::from(self.output.ct_eq(&other.output) & self.decoys.ct_eq(&other.decoys))
286  }
287}
288impl Eq for OutputWithDecoys {}
289
290impl OutputWithDecoys {
291  /// Select decoys for this output.
292  ///
293  /// The methodology used to sample decoys SHOULD prevent an RPC controlled by a passive adversary
294  /// from discovering the output actually being spent. An RPC controlled by an active adversary,
295  /// one who deliberately yields non-standard responses and provides a malicious view of the
296  /// Monero blockchain, may still be able to identify the output being spent. For privacy, please
297  /// only connect to trusted RPCs.
298  pub async fn new(
299    rng: &mut (impl Send + Sync + RngCore + CryptoRng),
300    rpc: &impl ProvidesDecoys,
301    ring_len: u8,
302    block_number: usize,
303    output: WalletOutput,
304  ) -> Result<OutputWithDecoys, TransactionsError> {
305    let decoys = select_decoys(rng, rpc, ring_len, block_number, &output, false).await?;
306    Ok(OutputWithDecoys { output: output.data.clone(), decoys })
307  }
308
309  /// Select a set of decoys for this output with a deterministic process.
310  ///
311  /// This function will always output the same set of decoys when called with the same arguments.
312  /// This makes it very useful in multisignature contexts, where instead of having one participant
313  /// select the decoys, everyone can locally select the decoys while coming to the same result.
314  ///
315  /// The set of decoys selected may be fingerprintable as having been produced by this
316  /// methodology.
317  ///
318  /// The methodology used to sample decoys SHOULD prevent an RPC controlled by a passive adversary
319  /// from discovering the output actually being spent. An RPC controlled by an active adversary,
320  /// one who deliberately yields non-standard responses and provides a malicious view of the
321  /// Monero blockchain, may still be able to identify the output being spent. For privacy, please
322  /// only connect to trusted RPCs.
323  pub async fn fingerprintable_deterministic_new(
324    rng: &mut (impl Send + Sync + RngCore + CryptoRng),
325    rpc: &impl ProvidesDecoys,
326    ring_len: u8,
327    block_number: usize,
328    output: WalletOutput,
329  ) -> Result<OutputWithDecoys, TransactionsError> {
330    let decoys = select_decoys(rng, rpc, ring_len, block_number, &output, true).await?;
331    Ok(OutputWithDecoys { output: output.data.clone(), decoys })
332  }
333
334  /// The key this output may be spent by.
335  pub fn key(&self) -> Point {
336    self.output.key()
337  }
338
339  /// The scalar to add to the private spend key for it to be the discrete logarithm of this
340  /// output's key.
341  pub fn key_offset(&self) -> Scalar {
342    self.output.key_offset
343  }
344
345  /// The commitment this output created.
346  pub fn commitment(&self) -> &Commitment {
347    &self.output.commitment
348  }
349
350  /// The decoys this output selected.
351  pub fn decoys(&self) -> &Decoys {
352    &self.decoys
353  }
354
355  /// Write the OutputWithDecoys.
356  ///
357  /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol
358  /// defined serialization. This may run in time variable to its value.
359  pub fn write<W: io::Write>(&self, w: &mut W) -> io::Result<()> {
360    self.output.write(w)?;
361    self.decoys.write(w)
362  }
363
364  /// Serialize the OutputWithDecoys to a `Vec<u8>`.
365  ///
366  /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol
367  /// defined serialization. This may run in time variable to its value.
368  pub fn serialize(&self) -> Vec<u8> {
369    let mut serialized = Vec::with_capacity(128);
370    self.write(&mut serialized).expect("write failed but <Vec as io::Write> doesn't fail");
371    serialized
372  }
373
374  /// Read an OutputWithDecoys.
375  ///
376  /// This is not a Monero protocol defined struct, and this is accordingly not a Monero protocol
377  /// defined serialization. This may run in time variable to its value.
378  pub fn read<R: io::Read>(r: &mut R) -> io::Result<Self> {
379    Ok(Self { output: OutputData::read(r)?, decoys: Decoys::read(r)? })
380  }
381}