Fix sweep sanity_check_failed on mainnet: add cross-input ring member deduplication
The daemon's tx_sanity_check requires ≥80% unique ring member indices across all inputs (when ≥10000 outputs exist). Our DecoySelector picked decoys independently per input, causing heavy overlap from the gamma distribution favoring recent outputs. Sweeps with many inputs failed on mainnet but passed on testnet (which has <10000 outputs, skipping the check entirely). Add build_ring_excluding() that avoids indices already used by prior inputs in the same transaction, with soft fallback after many attempts to prevent exhaustion on small output pools.
This commit is contained in:
@@ -156,6 +156,7 @@ impl<'a> TxPipeline<'a> {
|
||||
|
||||
let ring_size = salvium_tx::decoy::DEFAULT_RING_SIZE;
|
||||
let mut prepared = Vec::new();
|
||||
let mut used_ring_indices = std::collections::HashSet::new();
|
||||
|
||||
for inp in inputs {
|
||||
// Convert global_index → asset-type-specific index.
|
||||
@@ -193,10 +194,14 @@ impl<'a> TxPipeline<'a> {
|
||||
})?
|
||||
};
|
||||
|
||||
// Pick decoy indices in asset-type index space.
|
||||
// Pick decoy indices in asset-type index space, avoiding indices
|
||||
// already used by previous inputs in this transaction.
|
||||
let (ring_indices, real_pos) = decoy_selector
|
||||
.build_ring(asset_type_index, ring_size)
|
||||
.build_ring_excluding(asset_type_index, ring_size, &used_ring_indices)
|
||||
.map_err(|e| format!("ring build: {}", e))?;
|
||||
for &idx in &ring_indices {
|
||||
used_ring_indices.insert(idx);
|
||||
}
|
||||
|
||||
// Fetch ring member data using asset-type indices.
|
||||
let requests: Vec<salvium_rpc::daemon::OutputRequest> = ring_indices
|
||||
|
||||
@@ -736,7 +736,11 @@ pub async fn build_sign_maybe_broadcast(
|
||||
|
||||
// 2. For each selected UTXO, convert global index → asset-type index,
|
||||
// pick decoys, and fetch ring member data.
|
||||
// Track used ring member indices across inputs to prevent excessive
|
||||
// overlap that would fail the daemon's tx_sanity_check (requires ≥80%
|
||||
// unique ring members when mainnet has ≥10000 outputs).
|
||||
let mut prepared_inputs = Vec::new();
|
||||
let mut used_ring_indices = std::collections::HashSet::new();
|
||||
|
||||
for utxo in &selection.selected {
|
||||
// Look up the output row first (needed for public_key matching).
|
||||
@@ -785,10 +789,14 @@ pub async fn build_sign_maybe_broadcast(
|
||||
})?
|
||||
};
|
||||
|
||||
// Pick decoy indices in asset-type index space.
|
||||
// Pick decoy indices in asset-type index space, avoiding indices
|
||||
// already used by previous inputs in this transaction.
|
||||
let (ring_indices, real_pos) = decoy_selector
|
||||
.build_ring(asset_type_index, ring_size)
|
||||
.build_ring_excluding(asset_type_index, ring_size, &used_ring_indices)
|
||||
.map_err(|e| format!("build_ring failed: {e}"))?;
|
||||
for &idx in &ring_indices {
|
||||
used_ring_indices.insert(idx);
|
||||
}
|
||||
|
||||
// Fetch ring member data from daemon using asset-type indices.
|
||||
let requests: Vec<salvium_rpc::daemon::OutputRequest> = ring_indices
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
use crate::TxError;
|
||||
use rand::Rng;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Default ring size (16 = 15 decoys + 1 real).
|
||||
pub const DEFAULT_RING_SIZE: usize = 16;
|
||||
@@ -110,6 +111,64 @@ impl DecoySelector {
|
||||
Ok((decoys, real_pos))
|
||||
}
|
||||
|
||||
/// Build a ring while avoiding indices already used by other inputs in the
|
||||
/// same transaction.
|
||||
///
|
||||
/// This prevents the daemon's `tx_sanity_check` from rejecting multi-input
|
||||
/// transactions (e.g. sweeps) due to excessive ring member overlap.
|
||||
/// The `used` set should be updated by the caller after each call.
|
||||
pub fn build_ring_excluding(
|
||||
&self,
|
||||
real_index: u64,
|
||||
ring_size: usize,
|
||||
used: &HashSet<u64>,
|
||||
) -> Result<(Vec<u64>, usize), TxError> {
|
||||
let num_decoys = ring_size - 1;
|
||||
let mut rng = rand::thread_rng();
|
||||
let mut decoys = Vec::with_capacity(num_decoys);
|
||||
let mut attempts = 0;
|
||||
let max_attempts = num_decoys * 200; // more headroom with exclusions
|
||||
|
||||
while decoys.len() < num_decoys {
|
||||
attempts += 1;
|
||||
if attempts > max_attempts {
|
||||
return Err(TxError::DecoySelection(format!(
|
||||
"failed to find {} unique decoys after {} attempts (excluded {})",
|
||||
num_decoys,
|
||||
max_attempts,
|
||||
used.len()
|
||||
)));
|
||||
}
|
||||
|
||||
let idx = self.sample_output_index(&mut rng);
|
||||
|
||||
if idx == real_index || idx >= self.num_usable {
|
||||
continue;
|
||||
}
|
||||
if decoys.contains(&idx) {
|
||||
continue;
|
||||
}
|
||||
// Prefer unused indices but don't hard-reject used ones — we soften
|
||||
// the constraint after many attempts to avoid exhaustion when the
|
||||
// output pool is small.
|
||||
if used.contains(&idx) && attempts < num_decoys * 50 {
|
||||
continue;
|
||||
}
|
||||
|
||||
decoys.push(idx);
|
||||
}
|
||||
|
||||
decoys.push(real_index);
|
||||
decoys.sort_unstable();
|
||||
let real_pos = decoys.iter().position(|&x| x == real_index).unwrap();
|
||||
Ok((decoys, real_pos))
|
||||
}
|
||||
|
||||
/// Number of usable outputs in the distribution.
|
||||
pub fn num_usable_outputs(&self) -> u64 {
|
||||
self.num_usable
|
||||
}
|
||||
|
||||
/// Sample a single output index using the gamma distribution.
|
||||
fn sample_output_index<R: Rng>(&self, rng: &mut R) -> u64 {
|
||||
// Sample from gamma distribution.
|
||||
|
||||
Reference in New Issue
Block a user