Some UTs for `SelectCoinsForRound`
ADD
WalletWasabi.Tests/UnitTests/WabiSabi/Client/CoinJoinCoinSelectionTests.cs
+using System.Collections.Generic;
+using System.Linq;
+using NBitcoin;
+using WalletWasabi.Blockchain.Keys;
+using WalletWasabi.Blockchain.TransactionOutputs;
+using WalletWasabi.Crypto.Randomness;
+using WalletWasabi.Tests.Helpers;
+using WalletWasabi.WabiSabi.Client;
+using WalletWasabi.WabiSabi.Models;
+using WalletWasabi.WabiSabi.Models.MultipartyTransaction;
+using Xunit;
+
+namespace WalletWasabi.Tests.UnitTests.WabiSabi.Client;
+
+public class CoinJoinCoinSelectionTests
+{
+
+ [Fact]
+ public void SelectEmptySetOfCoins()
+ {
+ using SecureRandom rnd = new();
+ var coins = CoinJoinClient.SelectCoinsForRound(
+ coins: Enumerable.Empty<SmartCoin>(),
+ CreateMultipartyTransactionParameters(),
+ consolidationMode: false,
+ minAnonScoreTarget: 10,
+ rnd);
+
+ Assert.Empty(coins);
+ }
+
+ [Fact]
+ public void FullyPrivateSetOfCoins()
+ {
+ // This test is to make sure no coins are selected when all coins are private.
+ const int MinAnonimitySet = 10;
+ var km = KeyManager.CreateNew(out _, "", Network.Main);
+ var hdpubkey = BitcoinFactory.CreateHdPubKey(km);
+ var privateCoins = Enumerable
+ .Range(0, 10)
+ .Select(i => BitcoinFactory.CreateSmartCoin(hdpubkey, Money.Coins(1m), 0, anonymitySet: MinAnonimitySet + 1))
+ .ToList();
+
+ using SecureRandom rnd = new();
+ var coins = CoinJoinClient.SelectCoinsForRound(
+ coins: privateCoins,
+ CreateMultipartyTransactionParameters(),
+ consolidationMode: false,
+ minAnonScoreTarget: MinAnonimitySet,
+ rnd);
+
+ Assert.Empty(coins);
+ }
+
+ [Fact]
+ public void OnlyOneNonPrivateCoinInBigSetOfCoins()
+ {
+ // This test is to make sure that we don't select any coin when there is only one non-private coin in the set.
+ const int MinAnonimitySet = 10;
+ var km = KeyManager.CreateNew(out _, "", Network.Main);
+ var hdpubkey = BitcoinFactory.CreateHdPubKey(km);
+ var privateCoins = Enumerable
+ .Range(0, 10)
+ .Select(i => BitcoinFactory.CreateSmartCoin(hdpubkey, Money.Coins(1m), 0, anonymitySet: MinAnonimitySet + 1))
+ .Prepend(BitcoinFactory.CreateSmartCoin(hdpubkey, Money.Coins(1m), 0, anonymitySet: MinAnonimitySet - 1))
+ .ToList();
+
+ using SecureRandom rnd = new();
+ var coins = CoinJoinClient.SelectCoinsForRound(
+ coins: privateCoins,
+ CreateMultipartyTransactionParameters(),
+ consolidationMode: true,
+ minAnonScoreTarget: MinAnonimitySet,
+ rnd);
+
+ Assert.Empty(coins);
+ }
+
+ [Fact]
+ public void OnlyOneNonPrivateCoinInEmptySetOfCoins()
+ {
+ // This test is to make sure that we don't select the only non-private coin when it is the only coin in the wallaet.
+ const int MinAnonimitySet = 10;
+ var km = KeyManager.CreateNew(out _, "", Network.Main);
+ var hdpubkey = BitcoinFactory.CreateHdPubKey(km);
+ var privateCoins = Enumerable
+ .Empty<SmartCoin>()
+ .Prepend(BitcoinFactory.CreateSmartCoin(hdpubkey, Money.Coins(1m), 0, anonymitySet: MinAnonimitySet - 1))
+ .ToList();
+
+ using SecureRandom rnd = new();
+ var coins = CoinJoinClient.SelectCoinsForRound(
+ coins: privateCoins,
+ CreateMultipartyTransactionParameters(),
+ consolidationMode: false,
+ minAnonScoreTarget: MinAnonimitySet,
+ rnd);
+
+ Assert.Empty(coins);
+ }
+
+
+ [Fact]
+ public void TwoNonPrivateCoinInSetOfCoins()
+ {
+ // This test is to make sure that we never select two non-private coins.
+ const int MinAnonimitySet = 10;
+ var km = KeyManager.CreateNew(out _, "", Network.Main);
+ var hdpubkey = BitcoinFactory.CreateHdPubKey(km);
+ var privateCoins = Enumerable
+ .Empty<SmartCoin>()
+ .Prepend(BitcoinFactory.CreateSmartCoin(hdpubkey, Money.Coins(1m), 0, anonymitySet: MinAnonimitySet - 1))
+ .Prepend(BitcoinFactory.CreateSmartCoin(hdpubkey, Money.Coins(1m), 0, anonymitySet: MinAnonimitySet - 1))
+ .ToList();
+
+ using SecureRandom rnd = new();
+ var coins = CoinJoinClient.SelectCoinsForRound(
+ coins: privateCoins,
+ CreateMultipartyTransactionParameters(),
+ consolidationMode: false,
+ minAnonScoreTarget: MinAnonimitySet,
+ rnd);
+
+ Assert.Single(coins);
+ }
+
+
+ private static MultipartyTransactionParameters CreateMultipartyTransactionParameters()
+ {
+ var reasonableRange = new MoneyRange(Money.Coins(0.0001m), Money.Coins(430));
+ var txParams = new MultipartyTransactionParameters(
+ new FeeRate(5m),
+ CoordinationFeeRate.Zero,
+ reasonableRange,
+ reasonableRange,
+ Network.Main);
+ return txParams;
+ }
+}

MODIFY
WalletWasabi/WabiSabi/Client/CoinJoinClient.cs
WalletWasabi/WabiSabi/Client/CoinJoinClient.cs
@@ -21,7 +21,7 @@ namespace WalletWasabi.WabiSabi.Client;
public class CoinJoinClient
{
- private const int MaxInputsRegistrableByWallet = 10; // how many
+ const int MaxInputsRegistrableByWallet = 10; // how many
private volatile bool _inCriticalCoinJoinState;
/// <param name="minAnonScoreTarget">Coins those have reached anonymity target, but still can be mixed if desired.</param>
@@ -95,7 +95,7 @@ public class CoinJoinClient
{
var constructionState = roundState.Assert<ConstructionState>();
- var coinCandidates = SelectCoinsForRound(smartCoins, constructionState.Parameters);
+ var coinCandidates = SelectCoinsForRound(smartCoins, constructionState.Parameters, ConsolidationMode, MinAnonScoreTarget, SecureRandom);
// Register coins.
@@ -166,7 +166,7 @@ public class CoinJoinClient
// Send signature.
await SignTransactionAsync(registeredAliceClients, unsignedCoinJoin, roundState, cancellationToken).ConfigureAwait(false);
- Logger.LogDebug($"Round ({roundState.Id}): Alices({registeredAliceClients.Length}) successfully signed the coinjoin tx.");
+ Logger.LogDebug($"Round ({roundState.Id}): Alices({registeredAliceClients.Length}) successfully signed the CoinJoin tx.");
var finalRoundState = await RoundStatusUpdater.CreateRoundAwaiter(s => s.Id == roundState.Id && s.Phase == Phase.Ended, cancellationToken).ConfigureAwait(false);
Logger.LogDebug($"Round ({roundState.Id}): Round Ended - WasTransactionBroadcast: '{finalRoundState.WasTransactionBroadcast}'.");
@@ -304,7 +304,7 @@ public class CoinJoinClient
await Task.WhenAll(readyRequests).ConfigureAwait(false);
}
- private ImmutableList<SmartCoin> SelectCoinsForRound(IEnumerable<SmartCoin> coins, MultipartyTransactionParameters parameters)
+ internal static ImmutableList<SmartCoin> SelectCoinsForRound(IEnumerable<SmartCoin> coins, MultipartyTransactionParameters parameters, bool consolidationMode, int minAnonScoreTarget, SecureRandom rnd)
{
var filteredCoins = coins
.Where(x => parameters.AllowedInputAmounts.Contains(x.Amount))
@@ -315,13 +315,13 @@ public class CoinJoinClient
.ToArray();
// How many inputs do we want to provide to the mix?
- int inputCount = ConsolidationMode ? MaxInputsRegistrableByWallet : GetInputTarget(filteredCoins.Length);
+ int inputCount = consolidationMode ? MaxInputsRegistrableByWallet : GetInputTarget(filteredCoins.Length, rnd);
// Select a group of coins those are close to each other by Anonimity Score.
List<IEnumerable<SmartCoin>> groups = new();
// I can take more coins those are already reached the minimum privacy threshold.
- int nonPrivateCoinCount = filteredCoins.Count(x => x.HdPubKey.AnonymitySet < MinAnonScoreTarget);
+ int nonPrivateCoinCount = filteredCoins.Count(x => x.HdPubKey.AnonymitySet < minAnonScoreTarget);
for (int i = 0; i < nonPrivateCoinCount; i++)
{
// Make sure the group can at least register an output even after paying fees.
@@ -354,6 +354,11 @@ public class CoinJoinClient
anonScoreCosts.Add((cost, group));
}
+ if (anonScoreCosts.Count == 0)
+ {
+ return ImmutableList<SmartCoin>.Empty;
+ }
+
var bestCost = anonScoreCosts.Min(g => g.Cost);
var bestgroups = anonScoreCosts.Where(x => x.Cost == bestCost).Select(x => x.Group);
@@ -374,8 +379,9 @@ public class CoinJoinClient
/// Note: random biasing is applied.
/// </summary>
/// <returns>Desired input count.</returns>
- private int GetInputTarget(int utxoCount)
+ private static int GetInputTarget(int utxoCount, SecureRandom rnd)
{
+ const int MaxInputsRegistrableByWallet = 10; // how many
int targetInputCount;
if (utxoCount < 35)
{
@@ -402,7 +408,7 @@ public class CoinJoinClient
foreach (var best in distance.OrderBy(x => x.Value))
{
- if (SecureRandom.GetInt(0, 10) < 5)
+ if (rnd.GetInt(0, 10) < 5)
{
return best.Key;
}

Some UTs for `SelectCoinsForRound`