First version of instant clustering
MODIFY
WalletWasabi.Gui/Controls/WalletExplorer/CoinViewModel.cs
WalletWasabi.Gui/Controls/WalletExplorer/CoinViewModel.cs
@@ -161,7 +161,7 @@ namespace WalletWasabi.Gui.Controls.WalletExplorer
public string InCoinJoin => Model.CoinJoinInProgress ? "Yes" : "No";
- public string Clusters => string.IsNullOrEmpty(Model.Clusters) ? "" : Model.Clusters; //If the value is null the bind do not update the view. It shows the previous state for example: ##### even if PrivMode false.
+ public string Clusters => Model.Clusters.Labels; //If the value is null the bind do not update the view. It shows the previous state for example: ##### even if PrivMode false.
public string PubKey => Model.HdPubKey?.PubKey?.ToString() ?? "";

ADD
WalletWasabi.Tests/UnitTests/CoinsRegistryTests.cs
+using NBitcoin;
+using System;
+using System.IO;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading.Tasks;
+using WalletWasabi.KeyManagement;
+using WalletWasabi.Models;
+using WalletWasabi.Services;
+using WalletWasabi.Transactions;
+using Xunit;
+
+namespace WalletWasabi.Tests.UnitTests
+{
+ public class CoinsRegistryTests
+ {
+ [Fact]
+ public void OnlyUnspentCoinsAreListed()
+ {
+ var registry = new CoinsRegistry();
+ var spentCoin = Coin(0.2m);
+ registry.TryAdd(Coin(0.1m));
+ registry.TryAdd(spentCoin);
+ registry.TryAdd(Coin(0.4m));
+
+ registry.Spend(spentCoin);
+
+ Assert.Equal(2, registry.Count());
+ }
+
+ private static SmartCoin Coin(decimal amount, bool confirmed = true, int anonymitySet = 1)
+ {
+ var randomIndex = new Func<int>(() => new Random().Next(0, 200));
+ var height = confirmed ? new Height(randomIndex()) : Height.Mempool;
+ var slabel = new SmartLabel("");
+ var spentOutput = new[]
+ {
+ new TxoRef(RandomUtils.GetUInt256(), (uint)randomIndex())
+ };
+ var pubKey = new HdPubKey(new Key().PubKey, KeyPath.Parse("m/84h/0h/0h/0/1"), slabel, KeyState.Used);
+ return new SmartCoin(RandomUtils.GetUInt256(), (uint)randomIndex(), pubKey.P2wpkhScript, Money.Coins(amount), spentOutput, height, false, anonymitySet, false, slabel, pubKey: pubKey);
+ }
+
+ }
+}

MODIFY
WalletWasabi.Tests/UnitTests/TransactionProcessorTests.cs
WalletWasabi.Tests/UnitTests/TransactionProcessorTests.cs
@@ -129,7 +129,7 @@ namespace WalletWasabi.Tests.UnitTests
Assert.False(relevant);
Assert.Single(transactionProcessor.Coins, coin => coin.Unspent);
- Assert.Single(transactionProcessor.Coins, coin => !coin.Unspent);
+ Assert.Single(transactionProcessor.Coins.AsAllCoinsView(), coin => !coin.Unspent);
Assert.Equal(2, transactionProcessor.TransactionStore.GetTransactions().Count());
}
@@ -155,7 +155,7 @@ namespace WalletWasabi.Tests.UnitTests
Assert.True(relevant);
Assert.Single(transactionProcessor.Coins, coin => coin.Unspent && coin.Confirmed);
- Assert.Single(transactionProcessor.Coins, coin => !coin.Unspent && coin.Confirmed);
+ Assert.Single(transactionProcessor.Coins.AsAllCoinsView(), coin => !coin.Unspent && coin.Confirmed);
}
[Fact]
@@ -183,7 +183,7 @@ namespace WalletWasabi.Tests.UnitTests
Assert.True(relevant);
Assert.Single(transactionProcessor.Coins, coin => coin.Unspent && coin.Amount == Money.Coins(0.9m) && coin.IsReplaceable);
- Assert.Single(transactionProcessor.Coins, coin => !coin.Unspent && coin.Amount == Money.Coins(1.0m) && !coin.IsReplaceable);
+ Assert.Single(transactionProcessor.Coins.AsAllCoinsView(), coin => !coin.Unspent && coin.Amount == Money.Coins(1.0m) && !coin.IsReplaceable);
}
[Fact]
@@ -221,7 +221,7 @@ namespace WalletWasabi.Tests.UnitTests
var relevant = transactionProcessor.Process(tx);
Assert.True(relevant);
- var coin = Assert.Single(transactionProcessor.Coins);
+ var coin = Assert.Single(transactionProcessor.Coins.AsAllCoinsView());
Assert.False(coin.Unspent);
Assert.NotNull(spentCoin);
Assert.Equal(coin, spentCoin);
@@ -300,6 +300,96 @@ namespace WalletWasabi.Tests.UnitTests
Assert.True(coin.IsLikelyCoinJoinOutput); // because we are spanding and receiving almost the same amount
}
+ [Fact]
+ public async Task SimpleDirectClusteringAsync()
+ {
+ // --tx0---> (A) --tx1-+---> (pay to B)
+ // |
+ // +---> (change of B - cluster B, A) --tx2-+---> (pay to myself C - cluster C, B, A)
+ // |
+ // +---> (change of C - cluster C, B, A)
+ //
+ var transactionProcessor = await CreateTransactionProcessorAsync();
+ var key = transactionProcessor.NewKey("A");
+ var tx0 = CreateCreditingTransaction(key.P2wpkhScript, Money.Coins(1.0m));
+ transactionProcessor.Process(tx0);
+
+ var createdCoin = transactionProcessor.Coins.First();
+ Assert.Equal("A", createdCoin.Clusters.Labels);
+
+ // Spend the received coin to someone else B
+ var changeScript1 = transactionProcessor.NewKey("B").P2wpkhScript;
+ var tx1 = CreateSpendingTransaction(new[] { createdCoin.GetCoin() }, new Key().ScriptPubKey, changeScript1);
+ transactionProcessor.Process(tx1);
+ createdCoin = transactionProcessor.Coins.First();
+ Assert.Equal("B, A", createdCoin.Clusters.Labels);
+
+ // Spend the received coin to mylself else C
+ var myselfScript = transactionProcessor.NewKey("C").P2wpkhScript;
+ var changeScript2 = transactionProcessor.NewKey("").P2wpkhScript;
+ var tx2 = CreateSpendingTransaction(new[] { createdCoin.GetCoin() }, myselfScript, changeScript2);
+ transactionProcessor.Process(tx2);
+
+ Assert.Equal(2, transactionProcessor.Coins.Count());
+ createdCoin = transactionProcessor.Coins.First();
+ Assert.Equal("C, B, A", createdCoin.Clusters.Labels);
+
+ var createdchangeCoin = transactionProcessor.Coins.Last();
+ Assert.Equal("C, B, A", createdchangeCoin.Clusters.Labels);
+ }
+
+ [Fact]
+ public async Task MultipleDirectClusteringAsync()
+ {
+ // --tx0---> (A) --+
+ // |
+ // --tx1---> (B) --+---tx3-+--> (pay to D)
+ // | |
+ // --tx2---> (C) --+ +--> (change of D - cluster D, A, B, C)---+
+ // |
+ // +---tx5--+-->(pay to F)
+ // | |
+ // --tx4---> (E) ----------------------------------------------------+ +-->(change of F - cluster F, D, A, B, C, E)
+ var transactionProcessor = await CreateTransactionProcessorAsync();
+ var key = transactionProcessor.NewKey("A");
+ var tx0 = CreateCreditingTransaction(key.P2wpkhScript, Money.Coins(1.0m));
+ transactionProcessor.Process(tx0);
+
+ key = transactionProcessor.NewKey("B");
+ var tx1 = CreateCreditingTransaction(key.P2wpkhScript, Money.Coins(1.0m));
+ transactionProcessor.Process(tx1);
+
+ key = transactionProcessor.NewKey("C");
+ var tx2 = CreateCreditingTransaction(key.P2wpkhScript, Money.Coins(1.0m));
+ transactionProcessor.Process(tx2);
+
+ var scoinA = Assert.Single(transactionProcessor.Coins, c => c.Clusters.Labels == "A");
+ var scoinB = Assert.Single(transactionProcessor.Coins, c => c.Clusters.Labels == "B");
+ var scoinC = Assert.Single(transactionProcessor.Coins, c => c.Clusters.Labels == "C");
+
+ var changeScript = transactionProcessor.NewKey("D").P2wpkhScript;
+ var coins = new[] { scoinA.GetCoin(), scoinB.GetCoin(), scoinC.GetCoin() };
+ var tx3 = CreateSpendingTransaction(coins, new Key().ScriptPubKey, changeScript);
+ transactionProcessor.Process(tx3);
+
+ var changeCoinD = Assert.Single(transactionProcessor.Coins);
+ Assert.Equal("D, A, B, C", changeCoinD.Clusters.Labels);
+
+ key = transactionProcessor.NewKey("E");
+ var tx4 = CreateCreditingTransaction(key.P2wpkhScript, Money.Coins(1.0m));
+ transactionProcessor.Process(tx4);
+ var scoinE = Assert.Single(transactionProcessor.Coins, c => c.Clusters.Labels == "E");
+
+ changeScript = transactionProcessor.NewKey("F").P2wpkhScript;
+ coins = new[] { changeCoinD.GetCoin(), scoinE.GetCoin() };
+ var tx5 = CreateSpendingTransaction(coins, new Key().ScriptPubKey, changeScript);
+ transactionProcessor.Process(tx5);
+
+ var changeCoin = Assert.Single(transactionProcessor.Coins);
+ Assert.Equal("F, D, A, B, C, E", changeCoin.Clusters.Labels);
+ }
+
+
private async Task<TransactionProcessor> CreateTransactionProcessorAsync([CallerMemberName] string callerName = "")
{
var keyManager = KeyManager.CreateNew(out _, "password");
@@ -324,6 +414,21 @@ namespace WalletWasabi.Tests.UnitTests
return new SmartTransaction(tx, isConfirmed ? new Height(9999) : Height.Mempool);
}
+ private static SmartTransaction CreateSpendingTransaction(Coin[] coins, Script scriptPubKey, Script scriptPubKeyChange)
+ {
+ var tx = Network.RegTest.CreateTransaction();
+ var amount = Money.Zero;
+ foreach (var coin in coins)
+ {
+ tx.Inputs.Add(coin.Outpoint, Script.Empty, WitScript.Empty);
+ amount += coin.Amount;
+ }
+ tx.Outputs.Add(amount.Percentage(60), scriptPubKey ?? Script.Empty);
+ tx.Outputs.Add(amount.Percentage(40), scriptPubKeyChange);
+ return new SmartTransaction(tx, Height.Mempool);
+ }
+
+
private static SmartTransaction CreateCreditingTransaction(Script scriptPubKey, Money amount, bool isConfirmed = false)
{
var tx = Network.RegTest.CreateTransaction();
@@ -339,4 +444,13 @@ namespace WalletWasabi.Tests.UnitTests
return new OutPoint(RandomUtils.GetUInt256(), 0);
}
}
+
+ static class TransactionProcessorExtensions
+ {
+ public static HdPubKey NewKey(this TransactionProcessor me, string label)
+ {
+ var slable = new SmartLabel(label);
+ return me.KeyManager.GenerateNewKey(slable, KeyState.Clean, true);
+ }
+ }
}

MODIFY
WalletWasabi/Models/CoinsRegistry.cs
WalletWasabi/Models/CoinsRegistry.cs
@@ -12,10 +12,14 @@ namespace WalletWasabi.Models
private HashSet<SmartCoin> LatestCoinsSnapshot { get; set; }
private bool InvalidateSnapshot { get; set; }
private object Lock { get; set; }
+ private HashSet<SmartCoin> SpentCoins { get; }
+ // private Dictionary<SmartCoin, List<SmartCoin>> Clusters { get; }
public CoinsRegistry()
{
Coins = new HashSet<SmartCoin>();
+ SpentCoins = new HashSet<SmartCoin>();
+ // Clusters = new Dictionary<SmartCoin, List<SmartCoin>>();
LatestCoinsSnapshot = new HashSet<SmartCoin>();
InvalidateSnapshot = false;
Lock = new object();
@@ -47,7 +51,11 @@ namespace WalletWasabi.Models
lock (Lock)
{
added = Coins.Add(coin);
- InvalidateSnapshot |= added;
+ if (added)
+ {
+ // Clusters.Add(coin, new List<SmartCoin>() { coin });
+ InvalidateSnapshot = true;
+ }
}
return added;
}
@@ -56,29 +64,45 @@ namespace WalletWasabi.Models
{
var coinsToRemove = AsCoinsView().DescendantOf(coin).ToList();
coinsToRemove.Add(coin);
- var removedCoins = new List<SmartCoin>();
lock (Lock)
{
foreach (var toRemove in coinsToRemove)
{
if (Coins.Remove(toRemove))
{
- removedCoins.Add(toRemove);
+ //Clusters.Remove(toRemove);
}
}
InvalidateSnapshot = true;
}
}
- public void Clear()
+ public void Spend(SmartCoin spentCoin)
{
lock (Lock)
{
- Coins.Clear();
+ if (Coins.Remove(spentCoin))
+ {
+ SpentCoins.Add(spentCoin);
+ var createdCoins = Coins.Where(x => x.TransactionId == spentCoin.SpenderTransactionId);
+ foreach (var newCoin in createdCoins)
+ {
+ spentCoin.Clusters.Merge(newCoin.Clusters);
+ newCoin.Clusters = spentCoin.Clusters;
+ }
+ }
InvalidateSnapshot = true;
}
}
+ public ICoinsView AsAllCoinsView()
+ {
+ lock (Lock)
+ {
+ return new CoinsView(AsCoinsView().Concat(SpentCoins));
+ }
+ }
+
public ICoinsView AtBlockHeight(Height height)
{
return AsCoinsView().AtBlockHeight(height);

MODIFY
WalletWasabi/Models/SmartCoin.cs
WalletWasabi/Models/SmartCoin.cs
@@ -35,7 +35,8 @@ namespace WalletWasabi.Models
private HdPubKey _hdPubKey;
private ISecret _secret;
- private string _clusters;
+
+ private Cluster _clusters;
private bool _confirmed;
private bool _unavailable;
@@ -221,10 +222,10 @@ namespace WalletWasabi.Models
set => RaiseAndSetIfChanged(ref _secret, value);
}
- public string Clusters
+ public Cluster Clusters
{
get => _clusters;
- private set => RaiseAndSetIfChanged(ref _clusters, value);
+ set => RaiseAndSetIfChanged(ref _clusters, value);
}
#endregion NonSerializableProperties
@@ -336,6 +337,8 @@ namespace WalletWasabi.Models
Label = SmartLabel.Merge(HdPubKey?.Label, label);
+ Clusters = new Cluster(this);
+
SetConfirmed();
SetUnspent();
SetIsBanned();
@@ -361,11 +364,6 @@ namespace WalletWasabi.Models
return new TxoRef(TransactionId, Index);
}
- public void SetClusters(string clusters)
- {
- Clusters = clusters;
- }
-
#endregion Methods
#region EqualityAndComparison

ADD
WalletWasabi/Models/TransactionBuilding/Cluster.cs
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using WalletWasabi.Bases;
+
+namespace WalletWasabi.Models
+{
+ public class Cluster : NotifyPropertyChangedBase
+ {
+ private List<SmartCoin> Coins { get; set; }
+
+ private string _labels;
+
+ public Cluster(SmartCoin coin)
+ {
+ Labels = "";
+ Coins = new List<SmartCoin>();
+ Coins.Add(coin);
+ Labels = string.Join(", ", coin.Label.Labels);
+ }
+
+ public string Labels
+ {
+ get => _labels;
+ private set => RaiseAndSetIfChanged(ref _labels, value);
+ }
+
+ internal void Merge(Cluster clusters)
+ {
+ var insertPosition = 0;
+ foreach (var coin in clusters.Coins.ToList())
+ {
+ if (!Coins.Contains(coin))
+ {
+ Coins.Insert(insertPosition++, coin);
+ }
+ }
+ Labels = string.Join(", ", Coins.SelectMany(x => x.Label.Labels).Distinct(StringComparer.OrdinalIgnoreCase));
+ }
+ }
+}
\ No newline at end of file

MODIFY
WalletWasabi/Services/TransactionHistoryBuilder.cs
WalletWasabi/Services/TransactionHistoryBuilder.cs
@@ -27,7 +27,8 @@ namespace WalletWasabi.Services
var processedBlockTimeByHeight = walletService.ProcessedBlocks?.Values.ToDictionary(x => x.height, x => x.dateTime)
?? new Dictionary<Height, DateTimeOffset>();
- foreach (SmartCoin coin in walletService.Coins)
+ var allCoins = ((CoinsRegistry)walletService.Coins).AsAllCoinsView();
+ foreach (SmartCoin coin in allCoins)
{
var txId = coin.TransactionId;
if (txId is null || !walletService.BitcoinStore.TransactionStore.TryGetTransaction(txId, out SmartTransaction foundTransaction))

MODIFY
WalletWasabi/Services/TransactionProcessor.cs
WalletWasabi/Services/TransactionProcessor.cs
@@ -200,6 +200,7 @@ namespace WalletWasabi.Services
if (!alreadyKnown)
{
+ Coins.Spend(foundCoin);
CoinSpent?.Invoke(this, foundCoin);
}

MODIFY
WalletWasabi/Services/WalletService.cs
WalletWasabi/Services/WalletService.cs
@@ -140,7 +140,6 @@ namespace WalletWasabi.Services
private void TransactionProcessor_CoinSpent(object sender, SmartCoin spentCoin)
{
ChaumianClient.ExposedLinks.TryRemove(spentCoin.GetTxoRef(), out _);
- RefreshCoinHistories();
}
private async void TransactionProcessor_CoinReceivedAsync(object sender, SmartCoin newCoin)
@@ -159,7 +158,6 @@ namespace WalletWasabi.Services
Logger.LogError(ex);
}
}
- RefreshCoinHistories();
}
private static object TransactionProcessingLock { get; } = new object();
@@ -257,7 +255,6 @@ namespace WalletWasabi.Services
await LoadWalletStateAsync(cancel);
await LoadDummyMempoolAsync();
}
- RefreshCoinHistories();
}
private async Task LoadWalletStateAsync(CancellationToken cancel)
@@ -963,55 +960,8 @@ namespace WalletWasabi.Services
.SelectMany(x => x.Label.Labels))
.ToHashSet();
- private int _refreshCoinHistoriesRerunRequested = 0;
- private int _refreshCoinHistoriesRunning = 0;
-
public void RefreshCoinHistories()
{
- // If already running, then make sure another run is requested, else do the work.
- if (Interlocked.CompareExchange(ref _refreshCoinHistoriesRunning, 1, 0) == 1)
- {
- Interlocked.Exchange(ref _refreshCoinHistoriesRerunRequested, 1);
- return;
- }
-
- try
- {
- var unspentCoins = Coins.UnSpent(); //refreshing unspent coins clusters only
- if (unspentCoins.Any())
- {
- ILookup<Script, SmartCoin> lookupScriptPubKey = Coins.ToLookup(c => c.ScriptPubKey, c => c);
- ILookup<uint256, SmartCoin> lookupSpenderTransactionId = Coins.ToLookup(c => c.SpenderTransactionId, c => c);
- ILookup<uint256, SmartCoin> lookupTransactionId = Coins.ToLookup(c => c.TransactionId, c => c);
-
- const int simultaneousThread = 2; //threads allowed to run simultaneously in threadpool
-
- Parallel.ForEach(unspentCoins, new ParallelOptions { MaxDegreeOfParallelism = simultaneousThread }, coin =>
- {
- var result = string.Join(
- ", ",
- GetClusters(coin, new List<SmartCoin>(), lookupScriptPubKey, lookupSpenderTransactionId, lookupTransactionId)
- .SelectMany(x => x.Label.Labels)
- .Distinct(StringComparer.OrdinalIgnoreCase));
- coin.SetClusters(result);
- });
- }
- }
- catch (Exception ex)
- {
- Logger.LogError($"Refreshing coin clusters failed: {ex}.");
- }
- finally
- {
- // It's not running anymore, but someone may requested another run.
- Interlocked.Exchange(ref _refreshCoinHistoriesRunning, 0);
-
- // Clear the rerun request, too and if it was requested, then rerun.
- if (Interlocked.Exchange(ref _refreshCoinHistoriesRerunRequested, 0) == 1)
- {
- RefreshCoinHistories();
- }
- }
}
/// <summary>

First version of instant clustering