Monero: fix decoy selection algo and add test for latest spendable (#384)

* Monero: fix decoy selection algo and add test for latest spendable

- DSA only selected coinbase outputs and didn't match the wallet2
implementation
- Added test to make sure DSA will select a decoy output from the
most recent unlocked block
- Made usage of "height" in DSA consistent with other usage of
"height" in Monero code (height == num blocks in chain)
- Rely on monerod RPC response for output's unlocked status

* xmr runner tests mine until outputs are unlocked

* fingerprintable canoncial select decoys

* Separate fingerprintable canonical function

Makes it simpler for callers who are unconcered with consistent
canonical output selection across multiple clients to rely on
the simpler Decoy::select and not worry about fingerprintable
canonical

* fix merge conflicts

* Put back TODO for issue #104

* Fix incorrect check on distribution len

The RingCT distribution on mainnet doesn't start until well after
genesis, so the distribution length is expected to be < height.

To be clear, this was my mistake from this series of changes
to the DSA. I noticed this mistake because the DSA would error
when running on mainnet.
This commit is contained in:
Justin Berman 2024-02-19 18:34:10 -08:00 committed by GitHub
parent 4f1f7984a6
commit 92d8b91be9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 444 additions and 188 deletions

View file

@ -46,6 +46,10 @@ pub mod wallet;
#[cfg(test)]
mod tests;
pub const DEFAULT_LOCK_WINDOW: usize = 10;
pub const COINBASE_LOCK_WINDOW: usize = 60;
pub const BLOCK_TIME: usize = 120;
static INV_EIGHT_CELL: OnceLock<Scalar> = OnceLock::new();
#[allow(non_snake_case)]
pub(crate) fn INV_EIGHT() -> Scalar {

View file

@ -54,6 +54,15 @@ struct TransactionsResponse {
txs: Vec<TransactionResponse>,
}
#[derive(Deserialize, Debug)]
pub struct OutputResponse {
pub height: usize,
pub unlocked: bool,
key: String,
mask: String,
txid: String,
}
#[derive(Clone, PartialEq, Eq, Debug)]
#[cfg_attr(feature = "std", derive(thiserror::Error))]
pub enum RpcError {
@ -534,29 +543,15 @@ impl<R: RpcConnection> Rpc<R> {
Ok(distributions.distributions.swap_remove(0).distribution)
}
/// Get the specified outputs from the RingCT (zero-amount) pool, but only return them if their
/// timelock has been satisfied.
///
/// The timelock being satisfied is distinct from being free of the 10-block lock applied to all
/// Monero transactions.
pub async fn get_unlocked_outputs(
&self,
indexes: &[u64],
height: usize,
) -> Result<Vec<Option<[EdwardsPoint; 2]>>, RpcError> {
/// Get the specified outputs from the RingCT (zero-amount) pool
pub async fn get_outs(&self, indexes: &[u64]) -> Result<Vec<OutputResponse>, RpcError> {
#[derive(Deserialize, Debug)]
struct Out {
key: String,
mask: String,
txid: String,
struct OutsResponse {
status: String,
outs: Vec<OutputResponse>,
}
#[derive(Deserialize, Debug)]
struct Outs {
outs: Vec<Out>,
}
let outs: Outs = self
let res: OutsResponse = self
.rpc_call(
"get_outs",
Some(json!({
@ -569,15 +564,39 @@ impl<R: RpcConnection> Rpc<R> {
)
.await?;
let txs = self
if res.status != "OK" {
Err(RpcError::InvalidNode("bad response to get_outs".to_string()))?;
}
Ok(res.outs)
}
/// Get the specified outputs from the RingCT (zero-amount) pool, but only return them if their
/// timelock has been satisfied.
///
/// The timelock being satisfied is distinct from being free of the 10-block lock applied to all
/// Monero transactions.
pub async fn get_unlocked_outputs(
&self,
indexes: &[u64],
height: usize,
fingerprintable_canonical: bool,
) -> Result<Vec<Option<[EdwardsPoint; 2]>>, RpcError> {
let outs: Vec<OutputResponse> = self.get_outs(indexes).await?;
// Only need to fetch txs to do canonical check on timelock
let txs = if fingerprintable_canonical {
self
.get_transactions(
&outs.outs.iter().map(|out| hash_hex(&out.txid)).collect::<Result<Vec<_>, _>>()?,
&outs.iter().map(|out| hash_hex(&out.txid)).collect::<Result<Vec<_>, _>>()?,
)
.await?;
.await?
} else {
Vec::new()
};
// TODO: https://github.com/serai-dex/serai/issues/104
outs
.outs
.iter()
.enumerate()
.map(|(i, out)| {
@ -593,10 +612,13 @@ impl<R: RpcConnection> Rpc<R> {
) else {
return Ok(None);
};
Ok(
Some([key, rpc_point(&out.mask)?])
.filter(|_| Timelock::Block(height) >= txs[i].prefix.timelock),
)
Ok(Some([key, rpc_point(&out.mask)?]).filter(|_| {
if fingerprintable_canonical {
Timelock::Block(height) >= txs[i].prefix.timelock
} else {
out.unlocked
}
}))
})
.collect()
}
@ -713,13 +735,14 @@ impl<R: RpcConnection> Rpc<R> {
&self,
address: &str,
block_count: usize,
) -> Result<Vec<[u8; 32]>, RpcError> {
) -> Result<(Vec<[u8; 32]>, usize), RpcError> {
#[derive(Debug, Deserialize)]
struct BlocksResponse {
blocks: Vec<String>,
height: usize,
}
let block_strs = self
let res = self
.json_rpc_call::<BlocksResponse>(
"generateblocks",
Some(json!({
@ -727,13 +750,12 @@ impl<R: RpcConnection> Rpc<R> {
"amount_of_blocks": block_count
})),
)
.await?
.blocks;
.await?;
let mut blocks = Vec::with_capacity(block_strs.len());
for block in block_strs {
let mut blocks = Vec::with_capacity(res.blocks.len());
for block in res.blocks {
blocks.push(hash_hex(&block)?);
}
Ok(blocks)
Ok((blocks, res.height))
}
}

View file

@ -161,7 +161,9 @@ impl Timelock {
impl PartialOrd for Timelock {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
match (self, other) {
(Timelock::None, Timelock::None) => Some(Ordering::Equal),
(Timelock::None, _) => Some(Ordering::Less),
(_, Timelock::None) => Some(Ordering::Greater),
(Timelock::Block(a), Timelock::Block(b)) => a.partial_cmp(b),
(Timelock::Time(a), Timelock::Time(b)) => a.partial_cmp(b),
_ => None,

View file

@ -21,15 +21,13 @@ use crate::{
serialize::varint_len,
wallet::SpendableOutput,
rpc::{RpcError, RpcConnection, Rpc},
DEFAULT_LOCK_WINDOW, COINBASE_LOCK_WINDOW, BLOCK_TIME,
};
const LOCK_WINDOW: usize = 10;
const MATURITY: u64 = 60;
const RECENT_WINDOW: usize = 15;
const BLOCK_TIME: usize = 120;
const BLOCKS_PER_YEAR: usize = 365 * 24 * 60 * 60 / BLOCK_TIME;
#[allow(clippy::cast_precision_loss)]
const TIP_APPLICATION: f64 = (LOCK_WINDOW * BLOCK_TIME) as f64;
const TIP_APPLICATION: f64 = (DEFAULT_LOCK_WINDOW * BLOCK_TIME) as f64;
// TODO: Resolve safety of this in case a reorg occurs/the network changes
// TODO: Update this when scanning a block, as possible
@ -52,8 +50,10 @@ async fn select_n<'a, R: RngCore + CryptoRng, RPC: RpcConnection>(
real: &[u64],
used: &mut HashSet<u64>,
count: usize,
fingerprintable_canonical: bool,
) -> Result<Vec<(u64, [EdwardsPoint; 2])>, RpcError> {
if height >= rpc.get_height().await? {
// TODO: consider removing this extra RPC and expect the caller to handle it
if fingerprintable_canonical && height > rpc.get_height().await? {
// TODO: Don't use InternalError for the caller's failure
Err(RpcError::InternalError("decoys being requested from too young blocks"))?;
}
@ -64,6 +64,8 @@ async fn select_n<'a, R: RngCore + CryptoRng, RPC: RpcConnection>(
// Retries on failure. Retries are obvious as decoys, yet should be minimal
while confirmed.len() != count {
let remaining = count - confirmed.len();
// TODO: over-request candidates in case some are locked to avoid needing
// round trips to the daemon (and revealing obvious decoys to the daemon)
let mut candidates = Vec::with_capacity(remaining);
while candidates.len() != remaining {
#[cfg(test)]
@ -117,7 +119,14 @@ async fn select_n<'a, R: RngCore + CryptoRng, RPC: RpcConnection>(
}
}
for (i, output) in rpc.get_unlocked_outputs(&candidates, height).await?.iter_mut().enumerate() {
// TODO: make sure that the real output is included in the response, and
// that mask and key are equal to expected
for (i, output) in rpc
.get_unlocked_outputs(&candidates, height, fingerprintable_canonical)
.await?
.iter_mut()
.enumerate()
{
// Don't include the real spend as a decoy, despite requesting it
if real_indexes.contains(&i) {
continue;
@ -141,31 +150,13 @@ fn offset(ring: &[u64]) -> Vec<u64> {
res
}
/// Decoy data, containing the actual member as well (at index `i`).
#[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)]
pub struct Decoys {
pub(crate) i: u8,
pub(crate) offsets: Vec<u64>,
pub(crate) ring: Vec<[EdwardsPoint; 2]>,
}
#[allow(clippy::len_without_is_empty)]
impl Decoys {
pub fn fee_weight(offsets: &[u64]) -> usize {
varint_len(offsets.len()) + offsets.iter().map(|offset| varint_len(*offset)).sum::<usize>()
}
pub fn len(&self) -> usize {
self.offsets.len()
}
/// Select decoys using the same distribution as Monero.
pub async fn select<R: RngCore + CryptoRng, RPC: RpcConnection>(
async fn select_decoys<R: RngCore + CryptoRng, RPC: RpcConnection>(
rng: &mut R,
rpc: &Rpc<RPC>,
ring_len: usize,
height: usize,
inputs: &[SpendableOutput],
fingerprintable_canonical: bool,
) -> Result<Vec<Decoys>, RpcError> {
#[cfg(feature = "cache-distribution")]
#[cfg(not(feature = "std"))]
@ -187,19 +178,25 @@ impl Decoys {
outputs.push((real[real.len() - 1], [input.key(), input.commitment().calculate()]));
}
if distribution.len() <= height {
let extension = rpc.get_output_distribution(distribution.len(), height).await?;
if distribution.len() < height {
// TODO: verify distribution elems are strictly increasing
let extension =
rpc.get_output_distribution(distribution.len(), height.saturating_sub(1)).await?;
distribution.extend(extension);
}
// If asked to use an older height than previously asked, truncate to ensure accuracy
// Should never happen, yet risks desyncing if it did
distribution.truncate(height + 1); // height is inclusive, and 0 is a valid height
distribution.truncate(height);
if distribution.len() < DEFAULT_LOCK_WINDOW {
Err(RpcError::InternalError("not enough decoy candidates"))?;
}
let high = distribution[distribution.len() - 1];
#[allow(clippy::cast_precision_loss)]
let per_second = {
let blocks = distribution.len().min(BLOCKS_PER_YEAR);
let outputs = high - distribution[distribution.len().saturating_sub(blocks + 1)];
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)
};
@ -209,8 +206,11 @@ impl Decoys {
}
// TODO: Create a TX with less than the target amount, as allowed by the protocol
if (high - MATURITY) < u64::try_from(inputs.len() * ring_len).unwrap() {
Err(RpcError::InternalError("not enough decoy candidates"))?;
let high = distribution[distribution.len() - DEFAULT_LOCK_WINDOW];
if high.saturating_sub(COINBASE_LOCK_WINDOW as u64) <
u64::try_from(inputs.len() * ring_len).unwrap()
{
Err(RpcError::InternalError("not enough coinbase candidates"))?;
}
// Select all decoys for this transaction, assuming we generate a sane transaction
@ -226,6 +226,7 @@ impl Decoys {
&real,
&mut used,
inputs.len() * decoy_count,
fingerprintable_canonical,
)
.await?;
real.zeroize();
@ -275,6 +276,7 @@ impl Decoys {
&[],
&mut used,
ring_len - ring.len(),
fingerprintable_canonical,
)
.await?,
);
@ -295,4 +297,60 @@ impl Decoys {
Ok(res)
}
/// Decoy data, containing the actual member as well (at index `i`).
#[derive(Clone, PartialEq, Eq, Debug, Zeroize, ZeroizeOnDrop)]
pub struct Decoys {
pub(crate) i: u8,
pub(crate) offsets: Vec<u64>,
pub(crate) ring: Vec<[EdwardsPoint; 2]>,
}
#[allow(clippy::len_without_is_empty)]
impl Decoys {
pub fn fee_weight(offsets: &[u64]) -> usize {
varint_len(offsets.len()) + offsets.iter().map(|offset| varint_len(*offset)).sum::<usize>()
}
pub fn len(&self) -> usize {
self.offsets.len()
}
pub fn indexes(&self) -> Vec<u64> {
let mut res = vec![self.offsets[0]; self.len()];
for m in 1 .. res.len() {
res[m] = res[m - 1] + self.offsets[m];
}
res
}
/// Select decoys using the same distribution as Monero. Relies on the monerod RPC
/// response for an output's unlocked status, minimizing trips to the daemon.
pub async fn select<R: RngCore + CryptoRng, RPC: RpcConnection>(
rng: &mut R,
rpc: &Rpc<RPC>,
ring_len: usize,
height: usize,
inputs: &[SpendableOutput],
) -> Result<Vec<Decoys>, RpcError> {
select_decoys(rng, rpc, ring_len, height, inputs, false).await
}
/// If no reorg has occurred and an honest RPC, any caller who passes the same height to this
/// function will use the same distribution to select decoys. It is fingerprintable
/// because a caller using this will not be able to select decoys that are timelocked
/// with a timestamp. Any transaction which includes timestamp timelocked decoys in its
/// rings could not be constructed using this function.
///
/// TODO: upstream change to monerod get_outs RPC to accept a height param for checking
/// output's unlocked status and remove all usage of fingerprintable_canonical
pub async fn fingerprintable_canonical_select<R: RngCore + CryptoRng, RPC: RpcConnection>(
rng: &mut R,
rpc: &Rpc<RPC>,
ring_len: usize,
height: usize,
inputs: &[SpendableOutput],
) -> Result<Vec<Decoys>, RpcError> {
select_decoys(rng, rpc, ring_len, height, inputs, true).await
}
}

View file

@ -0,0 +1,162 @@
use monero_serai::{
transaction::Transaction,
wallet::SpendableOutput,
rpc::{Rpc, OutputResponse},
Protocol, DEFAULT_LOCK_WINDOW,
};
mod runner;
test!(
select_latest_output_as_decoy_canonical,
(
// First make an initial tx0
|_, mut builder: Builder, addr| async move {
builder.add_payment(addr, 2000000000000);
(builder.build().unwrap(), ())
},
|rpc: Rpc<_>, tx: Transaction, mut scanner: Scanner, _| async move {
let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0);
assert_eq!(output.commitment().amount, 2000000000000);
SpendableOutput::from(&rpc, output).await.unwrap()
},
),
(
// Then make a second tx1
|protocol: Protocol, rpc: Rpc<_>, mut builder: Builder, addr, state: _| async move {
let output_tx0: SpendableOutput = state;
let decoys = Decoys::fingerprintable_canonical_select(
&mut OsRng,
&rpc,
protocol.ring_len(),
rpc.get_height().await.unwrap(),
&[output_tx0.clone()],
)
.await
.unwrap();
let inputs = [output_tx0.clone()].into_iter().zip(decoys).collect::<Vec<_>>();
builder.add_inputs(&inputs);
builder.add_payment(addr, 1000000000000);
(builder.build().unwrap(), (protocol, output_tx0))
},
// Then make sure DSA selects freshly unlocked output from tx1 as a decoy
|rpc: Rpc<_>, tx: Transaction, mut scanner: Scanner, state: (_, _)| async move {
use rand_core::OsRng;
let height = rpc.get_height().await.unwrap();
let output_tx1 =
SpendableOutput::from(&rpc, scanner.scan_transaction(&tx).not_locked().swap_remove(0))
.await
.unwrap();
// Make sure output from tx1 is in the block in which it unlocks
let out_tx1: OutputResponse =
rpc.get_outs(&[output_tx1.global_index]).await.unwrap().swap_remove(0);
assert_eq!(out_tx1.height, height - DEFAULT_LOCK_WINDOW);
assert!(out_tx1.unlocked);
// Select decoys using spendable output from tx0 as the real, and make sure DSA selects
// the freshly unlocked output from tx1 as a decoy
let (protocol, output_tx0): (Protocol, SpendableOutput) = state;
let mut selected_fresh_decoy = false;
let mut attempts = 1000;
while !selected_fresh_decoy && attempts > 0 {
let decoys = Decoys::fingerprintable_canonical_select(
&mut OsRng, // TODO: use a seeded RNG to consistently select the latest output
&rpc,
protocol.ring_len(),
height,
&[output_tx0.clone()],
)
.await
.unwrap();
selected_fresh_decoy = decoys[0].indexes().contains(&output_tx1.global_index);
attempts -= 1;
}
assert!(selected_fresh_decoy);
assert_eq!(height, rpc.get_height().await.unwrap());
},
),
);
test!(
select_latest_output_as_decoy,
(
// First make an initial tx0
|_, mut builder: Builder, addr| async move {
builder.add_payment(addr, 2000000000000);
(builder.build().unwrap(), ())
},
|rpc: Rpc<_>, tx: Transaction, mut scanner: Scanner, _| async move {
let output = scanner.scan_transaction(&tx).not_locked().swap_remove(0);
assert_eq!(output.commitment().amount, 2000000000000);
SpendableOutput::from(&rpc, output).await.unwrap()
},
),
(
// Then make a second tx1
|protocol: Protocol, rpc: Rpc<_>, mut builder: Builder, addr, state: _| async move {
let output_tx0: SpendableOutput = state;
let decoys = Decoys::select(
&mut OsRng,
&rpc,
protocol.ring_len(),
rpc.get_height().await.unwrap(),
&[output_tx0.clone()],
)
.await
.unwrap();
let inputs = [output_tx0.clone()].into_iter().zip(decoys).collect::<Vec<_>>();
builder.add_inputs(&inputs);
builder.add_payment(addr, 1000000000000);
(builder.build().unwrap(), (protocol, output_tx0))
},
// Then make sure DSA selects freshly unlocked output from tx1 as a decoy
|rpc: Rpc<_>, tx: Transaction, mut scanner: Scanner, state: (_, _)| async move {
use rand_core::OsRng;
let height = rpc.get_height().await.unwrap();
let output_tx1 =
SpendableOutput::from(&rpc, scanner.scan_transaction(&tx).not_locked().swap_remove(0))
.await
.unwrap();
// Make sure output from tx1 is in the block in which it unlocks
let out_tx1: OutputResponse =
rpc.get_outs(&[output_tx1.global_index]).await.unwrap().swap_remove(0);
assert_eq!(out_tx1.height, height - DEFAULT_LOCK_WINDOW);
assert!(out_tx1.unlocked);
// Select decoys using spendable output from tx0 as the real, and make sure DSA selects
// the freshly unlocked output from tx1 as a decoy
let (protocol, output_tx0): (Protocol, SpendableOutput) = state;
let mut selected_fresh_decoy = false;
let mut attempts = 1000;
while !selected_fresh_decoy && attempts > 0 {
let decoys = Decoys::select(
&mut OsRng, // TODO: use a seeded RNG to consistently select the latest output
&rpc,
protocol.ring_len(),
height,
&[output_tx0.clone()],
)
.await
.unwrap();
selected_fresh_decoy = decoys[0].indexes().contains(&output_tx1.global_index);
attempts -= 1;
}
assert!(selected_fresh_decoy);
assert_eq!(height, rpc.get_height().await.unwrap());
},
),
);

View file

@ -17,6 +17,7 @@ use monero_serai::{
SpendableOutput, Fee,
},
transaction::Transaction,
DEFAULT_LOCK_WINDOW,
};
pub fn random_address() -> (Scalar, ViewPair, MoneroAddress) {
@ -36,7 +37,6 @@ pub fn random_address() -> (Scalar, ViewPair, MoneroAddress) {
// TODO: Support transactions already on-chain
// TODO: Don't have a side effect of mining blocks more blocks than needed under race conditions
// TODO: mine as much as needed instead of default 10 blocks
pub async fn mine_until_unlocked(rpc: &Rpc<HttpRpc>, addr: &str, tx_hash: [u8; 32]) {
// mine until tx is in a block
let mut height = rpc.get_height().await.unwrap();
@ -46,15 +46,23 @@ pub async fn mine_until_unlocked(rpc: &Rpc<HttpRpc>, addr: &str, tx_hash: [u8; 3
found = match block.txs.iter().find(|&&x| x == tx_hash) {
Some(_) => true,
None => {
rpc.generate_blocks(addr, 1).await.unwrap();
height += 1;
height = rpc.generate_blocks(addr, 1).await.unwrap().1 + 1;
false
}
}
}
// mine 9 more blocks to unlock the tx
rpc.generate_blocks(addr, 9).await.unwrap();
// Mine until tx's outputs are unlocked
let o_indexes: Vec<u64> = rpc.get_o_indexes(tx_hash).await.unwrap();
while rpc
.get_outs(&o_indexes)
.await
.unwrap()
.into_iter()
.all(|o| (!(o.unlocked && height >= (o.height + DEFAULT_LOCK_WINDOW))))
{
height = rpc.generate_blocks(addr, 1).await.unwrap().1 + 1;
}
}
// Mines 60 blocks and returns an unlocked miner TX output.
@ -260,12 +268,12 @@ macro_rules! test {
let temp = Box::new({
let mut builder = builder.clone();
let decoys = Decoys::select(
let decoys = Decoys::fingerprintable_canonical_select(
&mut OsRng,
&rpc,
protocol.ring_len(),
rpc.get_height().await.unwrap() - 1,
&[miner_tx.clone()]
rpc.get_height().await.unwrap(),
&[miner_tx.clone()],
)
.await
.unwrap();

View file

@ -24,11 +24,11 @@ async fn add_inputs(
spendable_outputs.push(SpendableOutput::from(rpc, output).await.unwrap());
}
let decoys = Decoys::select(
let decoys = Decoys::fingerprintable_canonical_select(
&mut OsRng,
rpc,
protocol.ring_len(),
rpc.get_height().await.unwrap() - 1,
rpc.get_height().await.unwrap(),
&spendable_outputs,
)
.await

View file

@ -338,7 +338,7 @@ impl Monero {
// All signers need to select the same decoys
// All signers use the same height and a seeded RNG to make sure they do so.
let decoys = Decoys::select(
let decoys = Decoys::fingerprintable_canonical_select(
&mut ChaCha20Rng::from_seed(transcript.rng_seed(b"decoys")),
&self.rpc,
protocol.ring_len(),
@ -742,11 +742,11 @@ impl Network for Monero {
let protocol = self.rpc.get_protocol().await.unwrap();
let decoys = Decoys::select(
let decoys = Decoys::fingerprintable_canonical_select(
&mut OsRng,
&self.rpc,
protocol.ring_len(),
self.rpc.get_height().await.unwrap() - 1,
self.rpc.get_height().await.unwrap(),
&outputs,
)
.await

View file

@ -100,7 +100,7 @@ async fn mint_and_burn_test() {
let rpc = producer_handles.monero(ops).await;
let mut res = Vec::with_capacity(count);
for _ in 0 .. count {
let block = rpc.get_block(rpc.generate_blocks(&addr, 1).await.unwrap()[0]).await.unwrap();
let block = rpc.get_block(rpc.generate_blocks(&addr, 1).await.unwrap().0[0]).await.unwrap();
let mut txs = Vec::with_capacity(block.txs.len());
for tx in &block.txs {
@ -360,11 +360,11 @@ async fn mint_and_burn_test() {
.unwrap()
.swap_remove(0);
let decoys = Decoys::select(
let decoys = Decoys::fingerprintable_canonical_select(
&mut OsRng,
&rpc,
Protocol::v16.ring_len(),
rpc.get_height().await.unwrap() - 1,
rpc.get_height().await.unwrap(),
&[output.clone()],
)
.await

View file

@ -308,11 +308,11 @@ impl Wallet {
.expect("prior transaction was never published"),
);
}
let mut decoys = Decoys::select(
let mut decoys = Decoys::fingerprintable_canonical_select(
&mut OsRng,
&rpc,
Protocol::v16.ring_len(),
rpc.get_height().await.unwrap() - 1,
rpc.get_height().await.unwrap(),
&these_inputs,
)
.await