Add address reuse tests
MODIFY
WalletWasabi.Tests/Helpers/BitcoinMock.cs
WalletWasabi.Tests/Helpers/BitcoinMock.cs
@@ -15,6 +15,12 @@ namespace WalletWasabi.Tests.Helpers
public static class BitcoinMock
{
public static SmartTransaction RandomSmartTransaction(int othersInputCount, IEnumerable<Money> othersOutputs, IEnumerable<(Money value, int anonset)> ownInputs, IEnumerable<(Money value, int anonset)> ownOutputs)
+ {
+ var km = RandomKeyManager();
+ return RandomSmartTransaction(othersInputCount, othersOutputs, ownInputs.Select(x => (x.value, x.anonset, RandomHdPubKey(km))), ownOutputs.Select(x => (x.value, x.anonset, RandomHdPubKey(km))));
+ }
+
+ public static SmartTransaction RandomSmartTransaction(int othersInputCount, IEnumerable<Money> othersOutputs, IEnumerable<(Money value, int anonset, HdPubKey hdpk)> ownInputs, IEnumerable<(Money value, int anonset, HdPubKey hdpk)> ownOutputs)
{
var km = RandomKeyManager();
var tx = Transaction.Create(Network.Main);
@@ -32,7 +38,7 @@ namespace WalletWasabi.Tests.Helpers
foreach (var txo in ownInputs)
{
idx++;
- var sc = RandomSmartCoin(RandomHdPubKey(km), txo.value, idx, anonymitySet: txo.anonset);
+ var sc = RandomSmartCoin(txo.hdpk, txo.value, idx, anonymitySet: txo.anonset);
tx.Inputs.Add(sc.OutPoint);
walletInputs.Add(sc);
}
@@ -46,7 +52,7 @@ namespace WalletWasabi.Tests.Helpers
foreach (var txo in ownOutputs)
{
idx++;
- var hdpk = RandomHdPubKey(km);
+ var hdpk = txo.hdpk;
tx.Outputs.Add(new TxOut(txo.value, hdpk.P2wpkhScript));
var sc = new SmartCoin(new SmartTransaction(tx, Height.Mempool), idx, hdpk);
walletOutputs.Add(sc);

MODIFY
WalletWasabi.Tests/UnitTests/BlockchainAnalysis/CoinJoinAnonscoreTests.cs
WalletWasabi.Tests/UnitTests/BlockchainAnalysis/CoinJoinAnonscoreTests.cs
@@ -185,7 +185,7 @@ namespace WalletWasabi.Tests.UnitTests.BlockchainAnalysis
analyser.Analyze(tx);
- Assert.True(tx.WalletInputs.First().HdPubKey.AnonymitySet < 100);
+ Assert.All(tx.WalletInputs, x => Assert.True(x.HdPubKey.AnonymitySet < 100));
// 10 participants, 1 is you, your added anonset would be 10.
// Smallest input anonset is 100 so your anonset would be 109 normally, but 4 inputs are merged so it should be worse.
@@ -205,7 +205,7 @@ namespace WalletWasabi.Tests.UnitTests.BlockchainAnalysis
analyser.Analyze(tx);
- Assert.Equal(1, tx.WalletInputs.First().HdPubKey.AnonymitySet);
+ Assert.All(tx.WalletInputs, x => Assert.Equal(1, x.HdPubKey.AnonymitySet));
// 10 participants, 1 is you, your anonset would be 10 normally and now too:
Assert.Equal(10, tx.WalletOutputs.First().HdPubKey.AnonymitySet);
@@ -239,7 +239,7 @@ namespace WalletWasabi.Tests.UnitTests.BlockchainAnalysis
analyser.Analyze(tx1);
analyser.Analyze(tx2);
- Assert.True(tx1.WalletInputs.First().HdPubKey.AnonymitySet > tx2.WalletInputs.First().HdPubKey.AnonymitySet);
+ Assert.All(tx1.WalletInputs, x => Assert.All(tx2.WalletInputs, y => Assert.True(x.HdPubKey.AnonymitySet > y.HdPubKey.AnonymitySet)));
Assert.True(tx1.WalletOutputs.First().HdPubKey.AnonymitySet > tx2.WalletOutputs.First().HdPubKey.AnonymitySet);
}
@@ -270,7 +270,7 @@ namespace WalletWasabi.Tests.UnitTests.BlockchainAnalysis
analyser.Analyze(tx1);
analyser.Analyze(tx2);
- Assert.True(tx1.WalletInputs.First().HdPubKey.AnonymitySet > tx2.WalletInputs.First().HdPubKey.AnonymitySet);
+ Assert.All(tx1.WalletInputs, x => Assert.All(tx2.WalletInputs, y => Assert.True(x.HdPubKey.AnonymitySet > y.HdPubKey.AnonymitySet)));
Assert.True(tx1.WalletOutputs.First().HdPubKey.AnonymitySet > tx2.WalletOutputs.First().HdPubKey.AnonymitySet);
}
}

MODIFY
WalletWasabi.Tests/UnitTests/BlockchainAnalysis/PubKeyReuseTests.cs
WalletWasabi.Tests/UnitTests/BlockchainAnalysis/PubKeyReuseTests.cs
+using NBitcoin;
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Text;
+using WalletWasabi.Blockchain.Keys;
+using WalletWasabi.Tests.Helpers;
+using Xunit;
namespace WalletWasabi.Tests.UnitTests.BlockchainAnalysis
{
public class PubKeyReuseTests
{
+ [Fact]
+ public void AddressReusePunishment()
+ {
+ // If there's reuse in input and output side, then output side didn't gain, nor lose anonymity.
+ var analyser = BitcoinMock.RandomBlockchainAnalyzer();
+ var km = BitcoinMock.RandomKeyManager();
+ var reuse = BitcoinMock.RandomHdPubKey(km);
+ var tx = BitcoinMock.RandomSmartTransaction(
+ 9,
+ Enumerable.Repeat(Money.Coins(1m), 9),
+ new[] { (Money.Coins(1.1m), 100, BitcoinMock.RandomHdPubKey(km)) },
+ new[] { (Money.Coins(1m), HdPubKey.DefaultHighAnonymitySet, reuse) });
+
+ // Make the reused key anonymity set something smaller than 109 (which should be the final anonymity set)
+ reuse.AnonymitySet = 30;
+
+ analyser.Analyze(tx);
+
+ Assert.All(tx.WalletInputs, x => Assert.Equal(100, x.HdPubKey.AnonymitySet));
+
+ // It should be smaller than 30, because reuse also gets punishment.
+ Assert.True(tx.WalletOutputs.First().HdPubKey.AnonymitySet < 30);
+ }
+
+ [Fact]
+ public void AddressReuseIrrelevantInNormalSpend()
+ {
+ // In normal transactions we expose to someone that we own the inputs and the changes
+ // So we cannot test address reuse here, because anonsets would be 1 regardless of anything.
+ var analyser = BitcoinMock.RandomBlockchainAnalyzer();
+ var km = BitcoinMock.RandomKeyManager();
+ var key = BitcoinMock.RandomHdPubKey(km);
+ var tx = BitcoinMock.RandomSmartTransaction(
+ 0,
+ Enumerable.Repeat(Money.Coins(1m), 9),
+ new[] { (Money.Coins(1.1m), 100, key), (Money.Coins(1.2m), 100, key), (Money.Coins(1.3m), 100, key), (Money.Coins(1.4m), 100, key) },
+ new[] { (Money.Coins(1m), HdPubKey.DefaultHighAnonymitySet, BitcoinMock.RandomHdPubKey(km)) });
+
+ analyser.Analyze(tx);
+
+ Assert.All(tx.WalletInputs, x => Assert.Equal(1, x.HdPubKey.AnonymitySet));
+ Assert.Equal(1, tx.WalletOutputs.First().HdPubKey.AnonymitySet);
+ }
+
+ [Fact]
+ public void InputSideAddressReuseHaveNoConsolidationPunishmentInSelfSpend()
+ {
+ var analyser = BitcoinMock.RandomBlockchainAnalyzer();
+ var km = BitcoinMock.RandomKeyManager();
+ var key = BitcoinMock.RandomHdPubKey(km);
+ var tx = BitcoinMock.RandomSmartTransaction(
+ 0,
+ Enumerable.Empty<Money>(),
+ new[] { (Money.Coins(1.1m), 100, key), (Money.Coins(1.2m), 100, key), (Money.Coins(1.3m), 100, key), (Money.Coins(1.4m), 100, key) },
+ new[] { (Money.Coins(1m), HdPubKey.DefaultHighAnonymitySet, BitcoinMock.RandomHdPubKey(km)) }); ;
+
+ analyser.Analyze(tx);
+
+ Assert.All(tx.WalletInputs, x => Assert.Equal(100, x.HdPubKey.AnonymitySet));
+ Assert.Equal(100, tx.WalletOutputs.First().HdPubKey.AnonymitySet);
+ }
+
+ [Fact]
+ public void InputSideAddressReuseHaveNoConsolidationPunishmentInCoinJoin()
+ {
+ var analyser = BitcoinMock.RandomBlockchainAnalyzer();
+ var km = BitcoinMock.RandomKeyManager();
+ var key = BitcoinMock.RandomHdPubKey(km);
+ var tx = BitcoinMock.RandomSmartTransaction(
+ 9,
+ Enumerable.Repeat(Money.Coins(1m), 9),
+ new[] { (Money.Coins(1.1m), 100, key), (Money.Coins(1.2m), 100, key), (Money.Coins(1.3m), 100, key), (Money.Coins(1.4m), 100, key) },
+ new[] { (Money.Coins(1m), HdPubKey.DefaultHighAnonymitySet, BitcoinMock.RandomHdPubKey(km)) });
+
+ analyser.Analyze(tx);
+
+ Assert.All(tx.WalletInputs, x => Assert.Equal(100, x.HdPubKey.AnonymitySet));
+ Assert.Equal(109, tx.WalletOutputs.First().HdPubKey.AnonymitySet);
+ }
+
+ [Fact]
+ public void InputOutputSideAddress()
+ {
+ // If there's reuse in input and output side, then output side didn't gain, nor lose anonymity.
+ var analyser = BitcoinMock.RandomBlockchainAnalyzer();
+ var key = BitcoinMock.RandomHdPubKey(BitcoinMock.RandomKeyManager());
+ var tx = BitcoinMock.RandomSmartTransaction(
+ 9,
+ Enumerable.Repeat(Money.Coins(1m), 9),
+ new[] { (Money.Coins(1.1m), 100, key) },
+ new[] { (Money.Coins(1m), HdPubKey.DefaultHighAnonymitySet, key) });
+
+ analyser.Analyze(tx);
+
+ Assert.All(tx.WalletInputs, x => Assert.Equal(100, x.HdPubKey.AnonymitySet));
+ Assert.Equal(100, tx.WalletOutputs.First().HdPubKey.AnonymitySet);
+ }
+
+ [Fact]
+ public void OutputSideAddressReusePunished()
+ {
+ // If there's reuse in input and output side, then output side didn't gain, nor lose anonymity.
+ var analyser = BitcoinMock.RandomBlockchainAnalyzer();
+ var km = BitcoinMock.RandomKeyManager();
+ var key = BitcoinMock.RandomHdPubKey(km);
+ var tx = BitcoinMock.RandomSmartTransaction(
+ 9,
+ Enumerable.Repeat(Money.Coins(1m), 9).Concat(Enumerable.Repeat(Money.Coins(2m), 7)),
+ new[] { (Money.Coins(1.1m), 100, BitcoinMock.RandomHdPubKey(km)) },
+ new[] { (Money.Coins(1m), HdPubKey.DefaultHighAnonymitySet, key), (Money.Coins(2m), HdPubKey.DefaultHighAnonymitySet, key) });
+
+ analyser.Analyze(tx);
+
+ Assert.All(tx.WalletInputs, x => Assert.Equal(100, x.HdPubKey.AnonymitySet));
+
+ // Normally all levels should have 109 and 106 anonsets, but they're consolidated and punished.
+ Assert.All(tx.WalletOutputs.Select(x => x.HdPubKey.AnonymitySet), x => Assert.True(x < 106));
+ }
+
+ [Fact]
+ public void OutputSideAddressReuseDoesntPunishedMoreThanInheritence()
+ {
+ // If there's reuse in input and output side, then output side didn't gain, nor lose anonymity.
+ var analyser = BitcoinMock.RandomBlockchainAnalyzer();
+ var km = BitcoinMock.RandomKeyManager();
+ var key = BitcoinMock.RandomHdPubKey(km);
+ var tx = BitcoinMock.RandomSmartTransaction(
+ 9,
+ Enumerable.Repeat(Money.Coins(1m), 9).Concat(Enumerable.Repeat(Money.Coins(2m), 8)).Concat(Enumerable.Repeat(Money.Coins(3m), 7)).Concat(Enumerable.Repeat(Money.Coins(4m), 6)).Concat(Enumerable.Repeat(Money.Coins(5m), 5)).Concat(Enumerable.Repeat(Money.Coins(6m), 4)),
+ new[] { (Money.Coins(1.1m), 100, BitcoinMock.RandomHdPubKey(km)) },
+ new[] { (Money.Coins(1m), HdPubKey.DefaultHighAnonymitySet, key), (Money.Coins(2m), HdPubKey.DefaultHighAnonymitySet, key), (Money.Coins(3m), HdPubKey.DefaultHighAnonymitySet, key), (Money.Coins(4m), HdPubKey.DefaultHighAnonymitySet, key), (Money.Coins(5m), HdPubKey.DefaultHighAnonymitySet, key), (Money.Coins(6m), HdPubKey.DefaultHighAnonymitySet, key) });
+
+ analyser.Analyze(tx);
+
+ Assert.All(tx.WalletInputs, x => Assert.Equal(100, x.HdPubKey.AnonymitySet));
+
+ // 100 is the input anonset, so outputs shouldn't go lower than that.
+ Assert.All(tx.WalletOutputs.Select(x => x.HdPubKey.AnonymitySet), x => Assert.True(x >= 100));
+ }
}
}

MODIFY
WalletWasabi/Blockchain/Analysis/BlockchainAnalyzer.cs
WalletWasabi/Blockchain/Analysis/BlockchainAnalyzer.cs
@@ -49,35 +49,13 @@ namespace WalletWasabi.Blockchain.Analysis
private void AnalyzeCoinjoin(SmartTransaction tx)
{
- var ownInputCount = tx.WalletInputs.Count;
- var inputCount = tx.Transaction.Inputs.Count;
- var distinctWalletInputPubKeys = tx.WalletInputs.Select(x => x.HdPubKey).ToHashSet();
- var inheritedAnonset = 0;
- if (ownInputCount > 0)
- {
- // If we provided inputs to the transaction.
- // Reusing pubkey on the input side is good, the punishment happened already.
- var distinctWalletInputPubKeyCount = distinctWalletInputPubKeys.Count;
- var pubKeyReuseCount = ownInputCount - distinctWalletInputPubKeyCount;
- var privacyBonus = Intersect(distinctWalletInputPubKeys.Select(x => x.AnonymitySet), (double)distinctWalletInputPubKeyCount / (inputCount - pubKeyReuseCount));
-
- // If the privacy bonus is <=1 then we are not inheriting any privacy from the inputs.
- var normalizedBonus = privacyBonus - 1;
-
- // And add that to the base anonset from the tx.
- inheritedAnonset = normalizedBonus;
-
- foreach (var key in tx.WalletInputs.Select(x => x.HdPubKey))
- {
- key.AnonymitySet = inheritedAnonset + 1;
- }
- }
+ AnalyzeWalletInputs(tx, out HashSet<HdPubKey> distinctWalletInputPubKeys, out int inheritedAnonset);
var indistinguishableWalletOutputs = tx
.WalletOutputs.GroupBy(x => x.Amount)
.ToDictionary(x => x.Key, y => y.Count());
- foreach (var newCoin in tx.WalletOutputs)
+ foreach (var newCoin in tx.WalletOutputs.ToArray())
{
// Get the anonymity set of i-th output in the transaction.
var anonset = inheritedAnonset;
@@ -85,7 +63,7 @@ namespace WalletWasabi.Blockchain.Analysis
// Calculating gained anonimity shall be limited by the number of inputs.
// Although one person may create many equal outputs and it would seem like we successfully get lost
// within this elephant, it's not the case as the elephant is more likely to ruin its own privacy, so let's not inflate anonsets for this.
- anonset += Math.Min(inputCount, tx.Transaction.GetAnonymitySet(newCoin.Index));
+ anonset += Math.Min(tx.Transaction.Inputs.Count, tx.Transaction.GetAnonymitySet(newCoin.Index));
// Don't create many anonset if we've provided a lot of equal outputs.
anonset -= indistinguishableWalletOutputs[newCoin.Amount] - 1;
@@ -104,6 +82,11 @@ namespace WalletWasabi.Blockchain.Analysis
// If it's a reuse of an input's pubkey, then intersection punishment is senseless.
hdPubKey.AnonymitySet = inheritedAnonset + 1;
}
+ else if (tx.WalletOutputs.Where(x => x != newCoin).Select(x => x.HdPubKey).Contains(hdPubKey))
+ {
+ // If it's a reuse of another output' pubkey, then intersection punishment can only go as low as the inherited anonset.
+ hdPubKey.AnonymitySet = Math.Max(inheritedAnonset + 1, Intersect(new[] { anonset, hdPubKey.AnonymitySet }, 1));
+ }
else
{
hdPubKey.AnonymitySet = Intersect(new[] { anonset, hdPubKey.AnonymitySet }, 1);
@@ -111,6 +94,32 @@ namespace WalletWasabi.Blockchain.Analysis
}
}
+ private void AnalyzeWalletInputs(SmartTransaction tx, out HashSet<HdPubKey> distinctWalletInputPubKeys, out int inheritedAnonset)
+ {
+ var ownInputCount = tx.WalletInputs.Count;
+ distinctWalletInputPubKeys = tx.WalletInputs.Select(x => x.HdPubKey).ToHashSet();
+ inheritedAnonset = 0;
+ if (ownInputCount > 0)
+ {
+ // If we provided inputs to the transaction.
+ // Reusing pubkey on the input side is good, the punishment happened already.
+ var distinctWalletInputPubKeyCount = distinctWalletInputPubKeys.Count;
+ var pubKeyReuseCount = ownInputCount - distinctWalletInputPubKeyCount;
+ var privacyBonus = Intersect(distinctWalletInputPubKeys.Select(x => x.AnonymitySet), (double)distinctWalletInputPubKeyCount / (tx.Transaction.Inputs.Count - pubKeyReuseCount));
+
+ // If the privacy bonus is <=1 then we are not inheriting any privacy from the inputs.
+ var normalizedBonus = privacyBonus - 1;
+
+ // And add that to the base anonset from the tx.
+ inheritedAnonset = normalizedBonus;
+
+ foreach (var key in tx.WalletInputs.Select(x => x.HdPubKey))
+ {
+ key.AnonymitySet = inheritedAnonset + 1;
+ }
+ }
+ }
+
private static void AnalyzeNormalSpend(SmartTransaction tx)
{
// If all our inputs are ours and there's more than one output that isn't,
@@ -128,13 +137,10 @@ namespace WalletWasabi.Blockchain.Analysis
private void AnalyzeSelfSpend(SmartTransaction tx)
{
- // If self spend then retain anonset:
- // Reusing pubkey on the input side is good, the punishment happened already.
- var distinctWalletInputPubKeys = tx.WalletInputs.Select(x => x.HdPubKey).ToHashSet();
- var anonset = Intersect(distinctWalletInputPubKeys.Select(x => x.AnonymitySet), 1);
- foreach (var key in tx.WalletInputs.Concat(tx.WalletOutputs).Select(x => x.HdPubKey))
+ AnalyzeWalletInputs(tx, out _, out int inheritedAnonset);
+ foreach (var key in tx.WalletOutputs.Select(x => x.HdPubKey))
{
- key.AnonymitySet = anonset;
+ key.AnonymitySet = inheritedAnonset + 1;
}
}

Add address reuse tests