Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Coin selection improvement #10096

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
15 changes: 15 additions & 0 deletions WalletWasabi.Tests/UnitTests/LinqExtensionsTests.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Linq;
using WalletWasabi.Extensions;
using Xunit;
Expand Down Expand Up @@ -107,4 +108,18 @@ public void MaxOrDefault()
Assert.Equal(4, new int[] { 4, 3 }.MaxOrDefault(defaultValue: 10));
Assert.Equal(4, new int[] { 4 }.MaxOrDefault(defaultValue: 10));
}

[Fact]
public void GeneralizedWeightedMean()
{
List<(double, double)> values = new() { (1.0, 1.1), (1.2, 1.3) };
Assert.Equal(1.097412680210462, values.GeneralizedWeightedMean(x => x.Item1, x => x.Item2, -1.4));
}

[Fact]
public void GeneralizedWeightedMeanOneP()
{
List<(double, double)> values = new() { (1.0, 1.1), (1.2, 1.3) };
Assert.Equal(values.WeightedMean(x => x.Item1, x => x.Item2), values.GeneralizedWeightedMean(x => x.Item1, x => x.Item2, 1.0));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,35 @@ public void SelectTwoNonPrivateCoinsFromTwoCoinsSetOfCoinsConsolidationMode()
Assert.Equal(2, coins.Count);
}

[Fact]
public void DoNotSelectCoinsWithBigAnonymityLoss()
{
// This test ensures that we do not select coins whose anonymity could be lowered a lot.
const int AnonymitySet = 10;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to switch from [Fact] to [Theory] with multiple AnonymitySet values to make the test more robust?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it would make sense. Nevertheless, it is not easy to generate such test vectors, because the coin selection algorithm is very complex and doesn't behave deterministically.

var km = KeyManager.CreateNew(out _, "", Network.Main);
var bigCoinWithSmallAnonymity1 = BitcoinFactory.CreateSmartCoin(BitcoinFactory.CreateHdPubKey(km), Money.Coins(1m), anonymitySet: 1);
var bigCoinWithSmallAnonymity2 = BitcoinFactory.CreateSmartCoin(BitcoinFactory.CreateHdPubKey(km), Money.Coins(1m), anonymitySet: 2);
var smallCoinWithBigAnonymity = BitcoinFactory.CreateSmartCoin(BitcoinFactory.CreateHdPubKey(km), Money.Coins(0.1m), anonymitySet: 6);
var coinsToSelectFrom = Enumerable
.Empty<SmartCoin>()
.Append(bigCoinWithSmallAnonymity1)
.Append(bigCoinWithSmallAnonymity2)
.Append(smallCoinWithBigAnonymity)
.ToList();
Comment on lines +163 to +168
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An alternative would be:

Suggested change
var coinsToSelectFrom = Enumerable
.Empty<SmartCoin>()
.Append(bigCoinWithSmallAnonymity1)
.Append(bigCoinWithSmallAnonymity2)
.Append(smallCoinWithBigAnonymity)
.ToList();
List<SmartCoin> coinsToSelectFrom = new() { bigCoinWithSmallAnonymity1, bigCoinWithSmallAnonymity2, smallCoinWithBigAnonymity };

or

Suggested change
var coinsToSelectFrom = Enumerable
.Empty<SmartCoin>()
.Append(bigCoinWithSmallAnonymity1)
.Append(bigCoinWithSmallAnonymity2)
.Append(smallCoinWithBigAnonymity)
.ToList();
List<SmartCoin> coinsToSelectFrom = new()
{
bigCoinWithSmallAnonymity1,
bigCoinWithSmallAnonymity2,
smallCoinWithBigAnonymity
};


var coins = CoinJoinClient.SelectCoinsForRound(
coins: coinsToSelectFrom,
UtxoSelectionParameters.FromRoundParameters(CreateMultipartyTransactionParameters()),
consolidationMode: true,
anonScoreTarget: AnonymitySet,
semiPrivateThreshold: 0,
liquidityClue: Money.Coins(0.5m),
ConfigureRng(1));

Assert.False(coins.Contains(bigCoinWithSmallAnonymity1) && coins.Contains(smallCoinWithBigAnonymity));
Assert.False(coins.Contains(bigCoinWithSmallAnonymity2) && coins.Contains(smallCoinWithBigAnonymity));
Comment on lines +179 to +180
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two checks make me think that the asserts are more general because we use random number generator but the rng returns a fixed number. Would it make sense to run this test, eg, 100 times with random RNG seed (i.e. make it random instead of deterministic) so that the test can actually fail if something is not as expected?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it would make sense. And I suppose the same holds true for other tests in this file. For this reason I suggest you to address it in another issue or pull request.

}

private static WasabiRandom ConfigureRng(int returnValue)
{
var mockWasabiRandom = new Mock<WasabiRandom>();
Expand Down
4 changes: 2 additions & 2 deletions WalletWasabi/Blockchain/Analysis/BlockchainAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ private static void AnalyzeCoinjoinWalletInputs(SmartTransaction tx, out double
// Consolidation in coinjoins is the only type of consolidation that's acceptable,
// because coinjoins are an exception from common input ownership heuristic.
// Calculate weighted average.
mixedAnonScore = CoinjoinAnalyzer.WeightedAverage(tx.WalletVirtualInputs.Select(x => new CoinjoinAnalyzer.AmountWithAnonymity(x.HdPubKey.AnonymitySet, x.Amount)));
mixedAnonScoreSanctioned = CoinjoinAnalyzer.WeightedAverage(tx.WalletVirtualInputs.Select(x => new CoinjoinAnalyzer.AmountWithAnonymity(x.HdPubKey.AnonymitySet + cjAnal.ComputeInputSanction(x, CoinjoinAnalyzer.WeightedAverage), x.Amount)));
mixedAnonScore = CoinjoinAnalyzer.WeightedMean(tx.WalletVirtualInputs.Select(x => new CoinjoinAnalyzer.AmountWithAnonymity(x.HdPubKey.AnonymitySet, x.Amount)));
mixedAnonScoreSanctioned = CoinjoinAnalyzer.WeightedMean(tx.WalletVirtualInputs.Select(x => new CoinjoinAnalyzer.AmountWithAnonymity(x.HdPubKey.AnonymitySet + cjAnal.ComputeInputSanction(x, CoinjoinAnalyzer.WeightedMean), x.Amount)));

nonMixedAnonScore = CoinjoinAnalyzer.Min(tx.WalletVirtualInputs.Select(x => new CoinjoinAnalyzer.AmountWithAnonymity(x.HdPubKey.AnonymitySet, x.Amount)));
nonMixedAnonScoreSanctioned = CoinjoinAnalyzer.Min(tx.WalletVirtualInputs.Select(x => new CoinjoinAnalyzer.AmountWithAnonymity(x.HdPubKey.AnonymitySet + cjAnal.ComputeInputSanction(x, CoinjoinAnalyzer.Min), x.Amount)));
Expand Down
2 changes: 1 addition & 1 deletion WalletWasabi/Blockchain/Analysis/CoinjoinAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public class CoinjoinAnalyzer
{
public static readonly int MaxRecursionDepth = 3;
public static readonly AggregationFunction Min = x => x.Any() ? x.Min(x => x.Anonymity) : 0;
public static readonly AggregationFunction WeightedAverage = x => x.Any() ? x.WeightedAverage(x => x.Anonymity, x => x.Amount.Satoshi) : 0;
public static readonly AggregationFunction WeightedMean = x => x.Any() ? x.WeightedMean(x => x.Anonymity, x => x.Amount.Satoshi) : 0;

public CoinjoinAnalyzer(SmartTransaction transaction)
{
Expand Down
39 changes: 38 additions & 1 deletion WalletWasabi/Extensions/LinqExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -209,11 +209,48 @@ public static IEnumerable<T> TakeUntil<T>(this IEnumerable<T> list, Func<T, bool
}
}

public static double WeightedAverage<T>(this IEnumerable<T> source, Func<T, double> value, Func<T, double> weight)
public static double WeightedMean<T>(this IEnumerable<T> source, Func<T, double> value, Func<T, double> weight)
{
return source.Select(x => value(x) * weight(x)).Sum() / source.Select(weight).Sum();
}

public static double GeneralizedWeightedMean<T>(this IEnumerable<T> source, Func<T, double> value, Func<T, double> weight, double p)
{
// See https://en.wikipedia.org/wiki/Generalized_mean
// Basic properties:
// * GeneralizedWeightedAverage(source, value, weight, 1) = WeightedAverage(source, value, weight)
// * GeneralizedWeightedAverage(source, value, weight, p) goes to Max(source, value) as p goes to the infinity
// * GeneralizedWeightedAverage(source, value, weight, p) goes to Min(source, value) as p goes to the minus infinity
// * GeneralizedWeightedAverage(source, value, weight, p) <= GeneralizedWeightedAverage(source, value, weight, q) provided p < q

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would check the parameters more:

Suggested change
if (p == 0)
{
throw new ArgumentException("Non-zero value is expected.", nameof(p));
}

If this is not added, it will crash later in because 1 / 0 is not defined (AFAIK).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 3cd7ea2.

if (!source.Any())
kiminuo marked this conversation as resolved.
Show resolved Hide resolved
{
throw new ArgumentException("Cannot be empty.", nameof(source));
}

if (source.Any(x => value(x) < 0))
{
throw new ArgumentException("Cannot be negative.", nameof(value));
}

if (source.Any(x => weight(x) < 0))
{
throw new ArgumentException("Cannot be negative.", nameof(weight));
}

if (source.All(x => weight(x) == 0))
{
throw new ArgumentException("Cannot be all zero.", nameof(weight));
}

if (p == 0)
{
throw new ArgumentException("Cannot be zero.", nameof(p));
}

return Math.Pow(source.Select(x => Math.Pow(value(x), p) * weight(x)).Sum() / source.Select(weight).Sum(), 1 / p);
}

public static int MaxOrDefault(this IEnumerable<int> me, int defaultValue) =>
me.DefaultIfEmpty(defaultValue).Max();
}
21 changes: 15 additions & 6 deletions WalletWasabi/WabiSabi/Client/CoinJoinClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ public async Task<CoinJoinResult> StartCoinJoinAsync(IEnumerable<SmartCoin> coin
case DisruptedCoinJoinResult info:
// Only use successfully registered coins in the blame round.
coins = info.SignedCoins;

currentRoundState.LogInfo("Waiting for the blame round.");
currentRoundState = await WaitForBlameRoundAsync(currentRoundState.Id, cancellationToken).ConfigureAwait(false);
break;
Expand All @@ -197,7 +197,7 @@ public async Task<CoinJoinResult> StartCoinJoinAsync(IEnumerable<SmartCoin> coin

case FailedCoinJoinResult failure:
return failure;

default:
throw new InvalidOperationException("The coinjoin result type was not handled.");
}
Expand Down Expand Up @@ -263,10 +263,10 @@ public async Task<CoinJoinResult> StartRoundAsync(IEnumerable<SmartCoin> smartCo
EndRoundState.None => "Unknown.",
_ => throw new ArgumentOutOfRangeException()
};

roundState.LogInfo(msg);
var signedCoins = aliceClientsThatSigned.Select(a => a.SmartCoin).ToImmutableList();

return roundState.EndRoundState switch
{
EndRoundState.TransactionBroadcasted => new SuccessfulCoinJoinResult(
Expand Down Expand Up @@ -530,7 +530,7 @@ internal static bool SanityCheck(IEnumerable<TxOut> expectedOutputs, IEnumerable
x => x.ScriptPubKey,
(coinjoinOutput, expectedOutput) => coinjoinOutput.Value - expectedOutput.Value)
.All(x => x >= 0L);

return AllExpectedScriptsArePresent() && AllOutputsHaveAtLeastTheExpectedValue();
}

Expand Down Expand Up @@ -947,8 +947,17 @@ private void LogCoinJoinSummary(ImmutableArray<AliceClient> registeredAliceClien
private static double GetAnonLoss<TCoin>(IEnumerable<TCoin> coins)
where TCoin : ISmartCoin
{
if (coins.Count() <= 1)
{
return 0;
}

// Parameters were picked experimentally to model anonymity loss that matches real-world experience: https://github.com/zkSNACKs/WalletWasabi/pull/10096
double p = 10;
kiminuo marked this conversation as resolved.
Show resolved Hide resolved
double q = 0.8;

double minimumAnonScore = coins.Min(x => x.AnonymitySet);
return coins.Sum(x => (x.AnonymitySet - minimumAnonScore) * x.Amount.Satoshi) / coins.Sum(x => x.Amount.Satoshi);
return coins.GeneralizedWeightedMean(value: x => x.AnonymitySet - minimumAnonScore, weight: x => Math.Pow(x.Amount.Satoshi, q), p);
}

private static int GetRandomBiasedSameTxAllowance(WasabiRandom rnd, int percent)
Expand Down