mirror of
https://github.com/serai-dex/serai.git
synced 2025-01-05 10:29:40 +00:00
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:
parent
4f1f7984a6
commit
92d8b91be9
10 changed files with 444 additions and 188 deletions
|
@ -46,6 +46,10 @@ pub mod wallet;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
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();
|
static INV_EIGHT_CELL: OnceLock<Scalar> = OnceLock::new();
|
||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
pub(crate) fn INV_EIGHT() -> Scalar {
|
pub(crate) fn INV_EIGHT() -> Scalar {
|
||||||
|
|
|
@ -54,6 +54,15 @@ struct TransactionsResponse {
|
||||||
txs: Vec<TransactionResponse>,
|
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)]
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||||
#[cfg_attr(feature = "std", derive(thiserror::Error))]
|
#[cfg_attr(feature = "std", derive(thiserror::Error))]
|
||||||
pub enum RpcError {
|
pub enum RpcError {
|
||||||
|
@ -534,29 +543,15 @@ impl<R: RpcConnection> Rpc<R> {
|
||||||
Ok(distributions.distributions.swap_remove(0).distribution)
|
Ok(distributions.distributions.swap_remove(0).distribution)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the specified outputs from the RingCT (zero-amount) pool, but only return them if their
|
/// Get the specified outputs from the RingCT (zero-amount) pool
|
||||||
/// timelock has been satisfied.
|
pub async fn get_outs(&self, indexes: &[u64]) -> Result<Vec<OutputResponse>, RpcError> {
|
||||||
///
|
|
||||||
/// 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> {
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
struct Out {
|
struct OutsResponse {
|
||||||
key: String,
|
status: String,
|
||||||
mask: String,
|
outs: Vec<OutputResponse>,
|
||||||
txid: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
let res: OutsResponse = self
|
||||||
struct Outs {
|
|
||||||
outs: Vec<Out>,
|
|
||||||
}
|
|
||||||
|
|
||||||
let outs: Outs = self
|
|
||||||
.rpc_call(
|
.rpc_call(
|
||||||
"get_outs",
|
"get_outs",
|
||||||
Some(json!({
|
Some(json!({
|
||||||
|
@ -569,15 +564,39 @@ impl<R: RpcConnection> Rpc<R> {
|
||||||
)
|
)
|
||||||
.await?;
|
.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(
|
.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
|
// TODO: https://github.com/serai-dex/serai/issues/104
|
||||||
outs
|
outs
|
||||||
.outs
|
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, out)| {
|
.map(|(i, out)| {
|
||||||
|
@ -593,10 +612,13 @@ impl<R: RpcConnection> Rpc<R> {
|
||||||
) else {
|
) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
Ok(
|
Ok(Some([key, rpc_point(&out.mask)?]).filter(|_| {
|
||||||
Some([key, rpc_point(&out.mask)?])
|
if fingerprintable_canonical {
|
||||||
.filter(|_| Timelock::Block(height) >= txs[i].prefix.timelock),
|
Timelock::Block(height) >= txs[i].prefix.timelock
|
||||||
)
|
} else {
|
||||||
|
out.unlocked
|
||||||
|
}
|
||||||
|
}))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
@ -713,13 +735,14 @@ impl<R: RpcConnection> Rpc<R> {
|
||||||
&self,
|
&self,
|
||||||
address: &str,
|
address: &str,
|
||||||
block_count: usize,
|
block_count: usize,
|
||||||
) -> Result<Vec<[u8; 32]>, RpcError> {
|
) -> Result<(Vec<[u8; 32]>, usize), RpcError> {
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct BlocksResponse {
|
struct BlocksResponse {
|
||||||
blocks: Vec<String>,
|
blocks: Vec<String>,
|
||||||
|
height: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
let block_strs = self
|
let res = self
|
||||||
.json_rpc_call::<BlocksResponse>(
|
.json_rpc_call::<BlocksResponse>(
|
||||||
"generateblocks",
|
"generateblocks",
|
||||||
Some(json!({
|
Some(json!({
|
||||||
|
@ -727,13 +750,12 @@ impl<R: RpcConnection> Rpc<R> {
|
||||||
"amount_of_blocks": block_count
|
"amount_of_blocks": block_count
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.await?
|
.await?;
|
||||||
.blocks;
|
|
||||||
|
|
||||||
let mut blocks = Vec::with_capacity(block_strs.len());
|
let mut blocks = Vec::with_capacity(res.blocks.len());
|
||||||
for block in block_strs {
|
for block in res.blocks {
|
||||||
blocks.push(hash_hex(&block)?);
|
blocks.push(hash_hex(&block)?);
|
||||||
}
|
}
|
||||||
Ok(blocks)
|
Ok((blocks, res.height))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -161,7 +161,9 @@ impl Timelock {
|
||||||
impl PartialOrd for Timelock {
|
impl PartialOrd for Timelock {
|
||||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||||
match (self, other) {
|
match (self, other) {
|
||||||
|
(Timelock::None, Timelock::None) => Some(Ordering::Equal),
|
||||||
(Timelock::None, _) => Some(Ordering::Less),
|
(Timelock::None, _) => Some(Ordering::Less),
|
||||||
|
(_, Timelock::None) => Some(Ordering::Greater),
|
||||||
(Timelock::Block(a), Timelock::Block(b)) => a.partial_cmp(b),
|
(Timelock::Block(a), Timelock::Block(b)) => a.partial_cmp(b),
|
||||||
(Timelock::Time(a), Timelock::Time(b)) => a.partial_cmp(b),
|
(Timelock::Time(a), Timelock::Time(b)) => a.partial_cmp(b),
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|
|
@ -21,15 +21,13 @@ use crate::{
|
||||||
serialize::varint_len,
|
serialize::varint_len,
|
||||||
wallet::SpendableOutput,
|
wallet::SpendableOutput,
|
||||||
rpc::{RpcError, RpcConnection, Rpc},
|
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 RECENT_WINDOW: usize = 15;
|
||||||
const BLOCK_TIME: usize = 120;
|
|
||||||
const BLOCKS_PER_YEAR: usize = 365 * 24 * 60 * 60 / BLOCK_TIME;
|
const BLOCKS_PER_YEAR: usize = 365 * 24 * 60 * 60 / BLOCK_TIME;
|
||||||
#[allow(clippy::cast_precision_loss)]
|
#[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: Resolve safety of this in case a reorg occurs/the network changes
|
||||||
// TODO: Update this when scanning a block, as possible
|
// 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],
|
real: &[u64],
|
||||||
used: &mut HashSet<u64>,
|
used: &mut HashSet<u64>,
|
||||||
count: usize,
|
count: usize,
|
||||||
|
fingerprintable_canonical: bool,
|
||||||
) -> Result<Vec<(u64, [EdwardsPoint; 2])>, RpcError> {
|
) -> 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
|
// TODO: Don't use InternalError for the caller's failure
|
||||||
Err(RpcError::InternalError("decoys being requested from too young blocks"))?;
|
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
|
// Retries on failure. Retries are obvious as decoys, yet should be minimal
|
||||||
while confirmed.len() != count {
|
while confirmed.len() != count {
|
||||||
let remaining = count - confirmed.len();
|
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);
|
let mut candidates = Vec::with_capacity(remaining);
|
||||||
while candidates.len() != remaining {
|
while candidates.len() != remaining {
|
||||||
#[cfg(test)]
|
#[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
|
// Don't include the real spend as a decoy, despite requesting it
|
||||||
if real_indexes.contains(&i) {
|
if real_indexes.contains(&i) {
|
||||||
continue;
|
continue;
|
||||||
|
@ -141,32 +150,14 @@ fn offset(ring: &[u64]) -> Vec<u64> {
|
||||||
res
|
res
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decoy data, containing the actual member as well (at index `i`).
|
async fn select_decoys<R: RngCore + CryptoRng, RPC: RpcConnection>(
|
||||||
#[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>(
|
|
||||||
rng: &mut R,
|
rng: &mut R,
|
||||||
rpc: &Rpc<RPC>,
|
rpc: &Rpc<RPC>,
|
||||||
ring_len: usize,
|
ring_len: usize,
|
||||||
height: usize,
|
height: usize,
|
||||||
inputs: &[SpendableOutput],
|
inputs: &[SpendableOutput],
|
||||||
) -> Result<Vec<Decoys>, RpcError> {
|
fingerprintable_canonical: bool,
|
||||||
|
) -> Result<Vec<Decoys>, RpcError> {
|
||||||
#[cfg(feature = "cache-distribution")]
|
#[cfg(feature = "cache-distribution")]
|
||||||
#[cfg(not(feature = "std"))]
|
#[cfg(not(feature = "std"))]
|
||||||
let mut distribution = DISTRIBUTION().lock();
|
let mut distribution = DISTRIBUTION().lock();
|
||||||
|
@ -187,19 +178,25 @@ impl Decoys {
|
||||||
outputs.push((real[real.len() - 1], [input.key(), input.commitment().calculate()]));
|
outputs.push((real[real.len() - 1], [input.key(), input.commitment().calculate()]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if distribution.len() <= height {
|
if distribution.len() < height {
|
||||||
let extension = rpc.get_output_distribution(distribution.len(), height).await?;
|
// TODO: verify distribution elems are strictly increasing
|
||||||
|
let extension =
|
||||||
|
rpc.get_output_distribution(distribution.len(), height.saturating_sub(1)).await?;
|
||||||
distribution.extend(extension);
|
distribution.extend(extension);
|
||||||
}
|
}
|
||||||
// If asked to use an older height than previously asked, truncate to ensure accuracy
|
// If asked to use an older height than previously asked, truncate to ensure accuracy
|
||||||
// Should never happen, yet risks desyncing if it did
|
// 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)]
|
#[allow(clippy::cast_precision_loss)]
|
||||||
let per_second = {
|
let per_second = {
|
||||||
let blocks = distribution.len().min(BLOCKS_PER_YEAR);
|
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)
|
(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
|
// 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() {
|
let high = distribution[distribution.len() - DEFAULT_LOCK_WINDOW];
|
||||||
Err(RpcError::InternalError("not enough decoy candidates"))?;
|
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
|
// Select all decoys for this transaction, assuming we generate a sane transaction
|
||||||
|
@ -226,6 +226,7 @@ impl Decoys {
|
||||||
&real,
|
&real,
|
||||||
&mut used,
|
&mut used,
|
||||||
inputs.len() * decoy_count,
|
inputs.len() * decoy_count,
|
||||||
|
fingerprintable_canonical,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
real.zeroize();
|
real.zeroize();
|
||||||
|
@ -275,6 +276,7 @@ impl Decoys {
|
||||||
&[],
|
&[],
|
||||||
&mut used,
|
&mut used,
|
||||||
ring_len - ring.len(),
|
ring_len - ring.len(),
|
||||||
|
fingerprintable_canonical,
|
||||||
)
|
)
|
||||||
.await?,
|
.await?,
|
||||||
);
|
);
|
||||||
|
@ -294,5 +296,61 @@ impl Decoys {
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(res)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
162
coins/monero/tests/decoys.rs
Normal file
162
coins/monero/tests/decoys.rs
Normal 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());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
|
@ -17,6 +17,7 @@ use monero_serai::{
|
||||||
SpendableOutput, Fee,
|
SpendableOutput, Fee,
|
||||||
},
|
},
|
||||||
transaction::Transaction,
|
transaction::Transaction,
|
||||||
|
DEFAULT_LOCK_WINDOW,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn random_address() -> (Scalar, ViewPair, MoneroAddress) {
|
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: Support transactions already on-chain
|
||||||
// TODO: Don't have a side effect of mining blocks more blocks than needed under race conditions
|
// 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]) {
|
pub async fn mine_until_unlocked(rpc: &Rpc<HttpRpc>, addr: &str, tx_hash: [u8; 32]) {
|
||||||
// mine until tx is in a block
|
// mine until tx is in a block
|
||||||
let mut height = rpc.get_height().await.unwrap();
|
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) {
|
found = match block.txs.iter().find(|&&x| x == tx_hash) {
|
||||||
Some(_) => true,
|
Some(_) => true,
|
||||||
None => {
|
None => {
|
||||||
rpc.generate_blocks(addr, 1).await.unwrap();
|
height = rpc.generate_blocks(addr, 1).await.unwrap().1 + 1;
|
||||||
height += 1;
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mine 9 more blocks to unlock the tx
|
// Mine until tx's outputs are unlocked
|
||||||
rpc.generate_blocks(addr, 9).await.unwrap();
|
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.
|
// Mines 60 blocks and returns an unlocked miner TX output.
|
||||||
|
@ -260,12 +268,12 @@ macro_rules! test {
|
||||||
let temp = Box::new({
|
let temp = Box::new({
|
||||||
let mut builder = builder.clone();
|
let mut builder = builder.clone();
|
||||||
|
|
||||||
let decoys = Decoys::select(
|
let decoys = Decoys::fingerprintable_canonical_select(
|
||||||
&mut OsRng,
|
&mut OsRng,
|
||||||
&rpc,
|
&rpc,
|
||||||
protocol.ring_len(),
|
protocol.ring_len(),
|
||||||
rpc.get_height().await.unwrap() - 1,
|
rpc.get_height().await.unwrap(),
|
||||||
&[miner_tx.clone()]
|
&[miner_tx.clone()],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
|
@ -24,11 +24,11 @@ async fn add_inputs(
|
||||||
spendable_outputs.push(SpendableOutput::from(rpc, output).await.unwrap());
|
spendable_outputs.push(SpendableOutput::from(rpc, output).await.unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
let decoys = Decoys::select(
|
let decoys = Decoys::fingerprintable_canonical_select(
|
||||||
&mut OsRng,
|
&mut OsRng,
|
||||||
rpc,
|
rpc,
|
||||||
protocol.ring_len(),
|
protocol.ring_len(),
|
||||||
rpc.get_height().await.unwrap() - 1,
|
rpc.get_height().await.unwrap(),
|
||||||
&spendable_outputs,
|
&spendable_outputs,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
|
@ -338,7 +338,7 @@ impl Monero {
|
||||||
|
|
||||||
// All signers need to select the same decoys
|
// All signers need to select the same decoys
|
||||||
// All signers use the same height and a seeded RNG to make sure they do so.
|
// 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")),
|
&mut ChaCha20Rng::from_seed(transcript.rng_seed(b"decoys")),
|
||||||
&self.rpc,
|
&self.rpc,
|
||||||
protocol.ring_len(),
|
protocol.ring_len(),
|
||||||
|
@ -742,11 +742,11 @@ impl Network for Monero {
|
||||||
|
|
||||||
let protocol = self.rpc.get_protocol().await.unwrap();
|
let protocol = self.rpc.get_protocol().await.unwrap();
|
||||||
|
|
||||||
let decoys = Decoys::select(
|
let decoys = Decoys::fingerprintable_canonical_select(
|
||||||
&mut OsRng,
|
&mut OsRng,
|
||||||
&self.rpc,
|
&self.rpc,
|
||||||
protocol.ring_len(),
|
protocol.ring_len(),
|
||||||
self.rpc.get_height().await.unwrap() - 1,
|
self.rpc.get_height().await.unwrap(),
|
||||||
&outputs,
|
&outputs,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
|
@ -100,7 +100,7 @@ async fn mint_and_burn_test() {
|
||||||
let rpc = producer_handles.monero(ops).await;
|
let rpc = producer_handles.monero(ops).await;
|
||||||
let mut res = Vec::with_capacity(count);
|
let mut res = Vec::with_capacity(count);
|
||||||
for _ in 0 .. 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());
|
let mut txs = Vec::with_capacity(block.txs.len());
|
||||||
for tx in &block.txs {
|
for tx in &block.txs {
|
||||||
|
@ -360,11 +360,11 @@ async fn mint_and_burn_test() {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.swap_remove(0);
|
.swap_remove(0);
|
||||||
|
|
||||||
let decoys = Decoys::select(
|
let decoys = Decoys::fingerprintable_canonical_select(
|
||||||
&mut OsRng,
|
&mut OsRng,
|
||||||
&rpc,
|
&rpc,
|
||||||
Protocol::v16.ring_len(),
|
Protocol::v16.ring_len(),
|
||||||
rpc.get_height().await.unwrap() - 1,
|
rpc.get_height().await.unwrap(),
|
||||||
&[output.clone()],
|
&[output.clone()],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
|
@ -308,11 +308,11 @@ impl Wallet {
|
||||||
.expect("prior transaction was never published"),
|
.expect("prior transaction was never published"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let mut decoys = Decoys::select(
|
let mut decoys = Decoys::fingerprintable_canonical_select(
|
||||||
&mut OsRng,
|
&mut OsRng,
|
||||||
&rpc,
|
&rpc,
|
||||||
Protocol::v16.ring_len(),
|
Protocol::v16.ring_len(),
|
||||||
rpc.get_height().await.unwrap() - 1,
|
rpc.get_height().await.unwrap(),
|
||||||
&these_inputs,
|
&these_inputs,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|
Loading…
Reference in a new issue