monero_wallet/
decoys.rs

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