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