Test transaction processing code (refactoring)
Detect coinjoin transactions
MODIFY
WalletWasabi.Gui/Controls/WalletExplorer/HistoryTabViewModel.cs
WalletWasabi.Gui/Controls/WalletExplorer/HistoryTabViewModel.cs
@@ -64,7 +64,7 @@ namespace WalletWasabi.Gui.Controls.WalletExplorer
Observable.FromEventPattern(Global.WalletService.Coins, nameof(Global.WalletService.Coins.CollectionChanged))
.Merge(Observable.FromEventPattern(Global.WalletService, nameof(Global.WalletService.NewBlockProcessed)))
- .Merge(Observable.FromEventPattern(Global.WalletService, nameof(Global.WalletService.CoinSpentOrSpenderConfirmed)))
+ .Merge(Observable.FromEventPattern(Global.WalletService.TransactionProcessor, nameof(Global.WalletService.TransactionProcessor.CoinSpentOrSpenderConfirmed)))
.Throttle(TimeSpan.FromSeconds(5))
.ObserveOn(RxApp.MainThreadScheduler)
.Subscribe(async _ => await TryRewriteTableAsync())
@@ -144,7 +144,7 @@ namespace WalletWasabi.Gui.Controls.WalletExplorer
{
var found = txRecordList.FirstOrDefault(x => x.transactionId == coin.TransactionId);
- SmartTransaction foundTransaction = walletService.TransactionCache?.FirstOrDefault(x => x.GetHash() == coin.TransactionId);
+ SmartTransaction foundTransaction = walletService.TransactionProcessor.TransactionCache?.FirstOrDefault(x => x.GetHash() == coin.TransactionId);
if (foundTransaction is null)
{
continue;
@@ -181,7 +181,7 @@ namespace WalletWasabi.Gui.Controls.WalletExplorer
if (coin.SpenderTransactionId != null)
{
- SmartTransaction foundSpenderTransaction = walletService.TransactionCache.First(x => x.GetHash() == coin.SpenderTransactionId);
+ SmartTransaction foundSpenderTransaction = walletService.TransactionProcessor.TransactionCache.First(x => x.GetHash() == coin.SpenderTransactionId);
if (foundSpenderTransaction.Height.Type == HeightType.Chain)
{

MODIFY
WalletWasabi.Gui/Controls/WalletExplorer/WalletViewModel.cs
WalletWasabi.Gui/Controls/WalletExplorer/WalletViewModel.cs
@@ -96,7 +96,7 @@ namespace WalletWasabi.Gui.Controls.WalletExplorer
Disposables = Disposables is null ? new CompositeDisposable() : throw new NotSupportedException($"Cannot open {GetType().Name} before closing it.");
Observable.FromEventPattern(Global.WalletService.Coins, nameof(Global.WalletService.Coins.CollectionChanged))
- .Merge(Observable.FromEventPattern(Global.WalletService, nameof(Global.WalletService.CoinSpentOrSpenderConfirmed)))
+ .Merge(Observable.FromEventPattern(Global.WalletService.TransactionProcessor, nameof(Global.WalletService.TransactionProcessor.CoinSpentOrSpenderConfirmed)))
.ObserveOn(RxApp.MainThreadScheduler)
.Subscribe(o => SetBalance(Name))
.DisposeWith(Disposables);

ADD
WalletWasabi.Tests/TransactionProcessorTests.cs
+using NBitcoin;
+using NBitcoin.Crypto;
+using System;
+using System.Linq;
+using WalletWasabi.KeyManagement;
+using WalletWasabi.Models;
+using WalletWasabi.Services;
+using Xunit;
+
+namespace WalletWasabi.Tests
+{
+ public class TransactionProcessorTests
+ {
+ [Fact]
+ public void TransactionDoesNotCointainCoinsForTheWallet()
+ {
+ var transactionProcessor = CreateTransactionProcessor();
+
+ // This transaction doesn't have any coin for the wallet. It is not relevant.
+ var tx = CreateCreditingTransaction(new Key().PubKey.WitHash.ScriptPubKey, Money.Coins(1.0m));
+
+ var relevant = transactionProcessor.Process(tx);
+
+ Assert.False(relevant);
+ Assert.Empty(transactionProcessor.Coins);
+ Assert.Empty(transactionProcessor.TransactionCache);
+ }
+
+
+ [Fact]
+ public void SpendToLegacyScripts()
+ {
+ var transactionProcessor = CreateTransactionProcessor();
+ var keys = transactionProcessor.KeyManager.GetKeys().ToArray();
+
+ // A payment to a key under our control but using P2PKH script (legacy)
+ var tx = CreateCreditingTransaction(keys.First().P2pkhScript, Money.Coins(1.0m));
+ var relevant = transactionProcessor.Process(tx);
+
+ Assert.False(relevant);
+ Assert.Empty(transactionProcessor.Coins);
+ }
+
+
+ [Fact]
+ public void UnconfirmedTransactionIsNotSegWit()
+ {
+ var transactionProcessor = CreateTransactionProcessor();
+
+ // No segwit transaction. Ignore it.
+ var tx = CreateCreditingTransaction(new Key().PubKey.Hash.ScriptPubKey, Money.Coins(1.0m));
+
+ var relevant = transactionProcessor.Process(tx);
+
+ Assert.False(relevant);
+ Assert.Empty(transactionProcessor.Coins);
+ Assert.Empty(transactionProcessor.TransactionCache);
+ }
+
+
+ [Fact]
+ public void ConfirmedTransactionIsNotSegWit()
+ {
+ var transactionProcessor = CreateTransactionProcessor();
+
+ // No segwit transaction. Ignore it.
+ var tx = CreateCreditingTransaction(new Key().PubKey.Hash.ScriptPubKey, Money.Coins(1.0m), isConfirmed: true);
+ transactionProcessor.TransactionHashes.Append(tx.GetHash()); // This transaction was already seen before
+
+ var relevant = transactionProcessor.Process(tx);
+
+ Assert.False(relevant);
+ Assert.Empty(transactionProcessor.Coins);
+ Assert.Empty(transactionProcessor.TransactionCache);
+ Assert.Empty(transactionProcessor.TransactionHashes);
+ }
+
+
+ [Fact]
+ public void UpdateTransactionHeightAfterConfirmation()
+ {
+ var transactionProcessor = CreateTransactionProcessor();
+
+ // An unconfirmed segwit transaction for us
+ var key = transactionProcessor.KeyManager.GetKeys().First();
+
+ var tx = CreateCreditingTransaction(key.PubKey.WitHash.ScriptPubKey, Money.Coins(1.0m));
+ transactionProcessor.TransactionHashes.Append(tx.GetHash()); // This transaction was already seen before
+ transactionProcessor.Process(tx);
+
+ var cachedTx = Assert.Single(transactionProcessor.TransactionCache);
+ var coin = Assert.Single(transactionProcessor.Coins);
+ Assert.Equal(Height.Mempool, cachedTx.Height);
+ Assert.Equal(Height.Mempool, coin.Height);
+
+ // Now it is confirmed
+ var blockHeight = new Height(77551);
+ tx = new SmartTransaction(tx.Transaction, blockHeight);
+ var relevant = transactionProcessor.Process(tx);
+
+ Assert.True(relevant);
+ Assert.Single(transactionProcessor.Coins);
+ cachedTx = Assert.Single(transactionProcessor.TransactionCache);
+ Assert.NotEqual(Height.Mempool, cachedTx.Height);
+ coin = Assert.Single(transactionProcessor.Coins);
+ Assert.Equal(blockHeight, coin.Height);
+
+ Assert.Empty(transactionProcessor.TransactionHashes);
+ }
+
+
+ [Fact]
+ public void IgnoreDoubleSpend()
+ {
+ var transactionProcessor = CreateTransactionProcessor();
+
+ var keys = transactionProcessor.KeyManager.GetKeys().ToArray();
+
+ // An unconfirmed segwit transaction for us
+ var tx = CreateCreditingTransaction(keys[0].PubKey.WitHash.ScriptPubKey, Money.Coins(1.0m));
+ transactionProcessor.Process(tx);
+
+ var createdCoin = tx.Transaction.Outputs.AsCoins().First();
+ // Spend the received coin
+ tx = CreateSpendingTransaction(createdCoin, keys[1].PubKey.WitHash.ScriptPubKey);
+ transactionProcessor.Process(tx);
+
+ // Spend it coin
+ tx = CreateSpendingTransaction(createdCoin, keys[2].PubKey.WitHash.ScriptPubKey);
+ var relevant = transactionProcessor.Process(tx);
+
+ Assert.False(relevant);
+ Assert.Single(transactionProcessor.Coins, coin => coin.Unspent);
+ Assert.Single(transactionProcessor.Coins, coin => !coin.Unspent);
+ Assert.Equal(2, transactionProcessor.TransactionCache.Count());
+ Assert.Empty(transactionProcessor.TransactionHashes);
+ }
+
+
+ [Fact]
+ public void ConfirmedDoubleSpend()
+ {
+ var transactionProcessor = CreateTransactionProcessor();
+
+ var keys = transactionProcessor.KeyManager.GetKeys().ToArray();
+
+ // An unconfirmed segwit transaction for us
+ var tx = CreateCreditingTransaction(keys[0].PubKey.WitHash.ScriptPubKey, Money.Coins(1.0m), isConfirmed: true);
+ transactionProcessor.Process(tx);
+
+ var createdCoin = tx.Transaction.Outputs.AsCoins().First();
+ // Spend the received coin
+ tx = CreateSpendingTransaction(createdCoin, keys[1].PubKey.WitHash.ScriptPubKey);
+ transactionProcessor.Process(tx);
+
+ // Spend it coin
+ tx = CreateSpendingTransaction(createdCoin, keys[2].PubKey.WitHash.ScriptPubKey, isConfirmed: true);
+ var relevant = transactionProcessor.Process(tx);
+
+ Assert.True(relevant);
+ Assert.Single(transactionProcessor.Coins, coin => coin.Unspent && coin.Confirmed);
+ Assert.Single(transactionProcessor.Coins, coin => !coin.Unspent && coin.Confirmed);
+ Assert.Empty(transactionProcessor.TransactionHashes);
+ }
+
+
+ [Fact]
+ public void HandlesRBF()
+ {
+ var transactionProcessor = CreateTransactionProcessor();
+
+ var keys = transactionProcessor.KeyManager.GetKeys().ToArray();
+
+ // A confirmed segwit transaction for us
+ var tx = CreateCreditingTransaction(keys[0].PubKey.WitHash.ScriptPubKey, Money.Coins(1.0m), isConfirmed: true);
+ transactionProcessor.Process(tx);
+
+ var createdCoin = tx.Transaction.Outputs.AsCoins().First();
+ // Spend the received coin
+ tx = CreateSpendingTransaction(createdCoin, keys[1].PubKey.WitHash.ScriptPubKey);
+ tx.Transaction.Inputs[0].Sequence = Sequence.OptInRBF;
+ var relevant = transactionProcessor.Process(tx);
+ Assert.True(relevant);
+
+ // Spend it coin
+ tx = CreateSpendingTransaction(createdCoin, keys[2].PubKey.WitHash.ScriptPubKey);
+ tx.Transaction.Outputs[0].Value = Money.Coins(0.9m);
+ relevant = transactionProcessor.Process(tx);
+
+ 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.Empty(transactionProcessor.TransactionHashes);
+ }
+
+
+ [Fact]
+ public void ReceiveTransactionForWallet()
+ {
+ var transactionProcessor = CreateTransactionProcessor();
+ SmartCoin receivedCoin = null;
+ transactionProcessor.CoinReceived += (s, theCoin)=>receivedCoin = theCoin;
+ var keys = transactionProcessor.KeyManager.GetKeys();
+ var tx = CreateCreditingTransaction(keys.First().P2wpkhScript, Money.Coins(1.0m));
+
+ var relevant = transactionProcessor.Process(tx);
+
+ // It is relevant because is funding the wallet
+ Assert.True(relevant);
+ var coin = Assert.Single(transactionProcessor.Coins);
+ Assert.Equal(Money.Coins(1.0m), coin.Amount);
+ Assert.Contains(transactionProcessor.TransactionCache, x=>x == tx);
+ Assert.NotNull(receivedCoin);
+ }
+
+
+ [Fact]
+ public void SpendCoin()
+ {
+ var transactionProcessor = CreateTransactionProcessor();
+ SmartCoin spentCoin = null;
+ transactionProcessor.CoinSpentOrSpenderConfirmed += (s, theCoin)=>spentCoin = theCoin;
+ var keys = transactionProcessor.KeyManager.GetKeys();
+ var tx = CreateCreditingTransaction(keys.First().P2wpkhScript, Money.Coins(1.0m));
+ transactionProcessor.Process(tx);
+
+ var createdCoin = tx.Transaction.Outputs.AsCoins().First();
+ // Spend the received coin
+ tx = CreateSpendingTransaction(createdCoin, new Key().PubKey.ScriptPubKey);
+ var relevant = transactionProcessor.Process(tx);
+
+ Assert.True(relevant);
+ var coin = Assert.Single(transactionProcessor.Coins);
+ Assert.False(coin.Unspent);
+ Assert.NotNull(spentCoin);
+ Assert.Equal(coin, spentCoin);
+ }
+
+
+
+ [Fact]
+ public void ReceiveTransactionWithDustForWallet()
+ {
+ var transactionProcessor = CreateTransactionProcessor();
+ transactionProcessor.CoinReceived += (s, theCoin)
+ => throw new Exception("The dust coin raised event when it shouldn't");
+ var keys = transactionProcessor.KeyManager.GetKeys();
+ var tx = CreateCreditingTransaction(keys.First().P2wpkhScript, Money.Coins(0.000099m));
+
+ var relevant = transactionProcessor.Process(tx);
+
+ // It is relevant even when all the coins can be dust.
+ Assert.True(relevant);
+ Assert.Empty(transactionProcessor.Coins);
+ }
+
+
+ [Fact]
+ public void ReceiveCoinjoinTransaction()
+ {
+ var transactionProcessor = CreateTransactionProcessor();
+ var keys = transactionProcessor.KeyManager.GetKeys();
+
+ var amount = Money.Coins(0.1m);
+
+ var tx = Network.RegTest.CreateTransaction();
+ tx.Version = 1;
+ tx.LockTime = LockTime.Zero;
+ tx.Outputs.Add(amount, keys.First().P2wpkhScript);
+ var txout = new TxOut(Money.Coins(0.1m), new Key().PubKey.WitHash.ScriptPubKey);
+ tx.Outputs.AddRange(Enumerable.Repeat(txout, 5)); // 6 indistinguishable txouts
+ tx.Inputs.AddRange(Enumerable.Repeat(new TxIn(GetRandomOutPoint(), Script.Empty), 4));
+
+
+ var relevant = transactionProcessor.Process(new SmartTransaction(tx, Height.Mempool));
+
+ // It is relevant even when all the coins can be dust.
+ Assert.True(relevant);
+ var coin = Assert.Single(transactionProcessor.Coins);
+ Assert.Equal(4, coin.AnonymitySet);
+ Assert.Equal(Money.Coins(0.1m), coin.Amount);
+ }
+
+
+
+ private static TransactionProcessor CreateTransactionProcessor()
+ {
+ var keyManager = KeyManager.CreateNew(out _, "password");
+ keyManager.AssertCleanKeysIndexed();
+ return new TransactionProcessor(
+ keyManager,
+ new ConcurrentHashSet<uint256>(),
+ new ObservableConcurrentHashSet<SmartCoin>(),
+ Money.Coins(0.0001m));
+ }
+
+
+ private static SmartTransaction CreateSpendingTransaction(Coin coin, Script scriptPubKey = null, bool isConfirmed = false)
+ {
+ var tx = Network.RegTest.CreateTransaction();
+ tx.Inputs.Add(coin.Outpoint, Script.Empty, WitScript.Empty);
+ tx.Outputs.Add(coin.Amount, scriptPubKey ?? Script.Empty);
+ return new SmartTransaction(tx, isConfirmed ? new Height(9999) : Height.Mempool);
+ }
+
+ private static SmartTransaction CreateCreditingTransaction(Script scriptPubKey, Money amount, bool isConfirmed = false)
+ {
+ var tx = Network.RegTest.CreateTransaction();
+ tx.Version = 1;
+ tx.LockTime = LockTime.Zero;
+ tx.Inputs.Add( GetRandomOutPoint(), new Script(OpcodeType.OP_0, OpcodeType.OP_0), sequence: Sequence.Final);
+ tx.Outputs.Add(amount, scriptPubKey);
+ return new SmartTransaction(tx, isConfirmed ? new Height(9999) : Height.Mempool);
+ }
+
+ private static OutPoint GetRandomOutPoint()
+ {
+ return new OutPoint(RandomUtils.GetUInt256(), 0);
+ }
+ }
+}

ADD
WalletWasabi/Services/TransactionProcessor.cs
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using NBitcoin;
+using WalletWasabi.KeyManagement;
+using WalletWasabi.Models;
+
+namespace WalletWasabi.Services
+{
+ public class TransactionProcessor
+ {
+ public KeyManager KeyManager { get; }
+ public ConcurrentHashSet<uint256> TransactionHashes { get; }
+ public ConcurrentHashSet<SmartTransaction> TransactionCache { get; }
+ public ObservableConcurrentHashSet<SmartCoin> Coins { get; }
+ public Money DustThreshold { get; }
+ public event EventHandler<SmartCoin> CoinSpentOrSpenderConfirmed;
+ public event EventHandler<SmartCoin> CoinReceived;
+
+ public TransactionProcessor(KeyManager keyManager,
+ ConcurrentHashSet<uint256> transactionHashes,
+ ObservableConcurrentHashSet<SmartCoin> coins,
+ Money dustThreshold
+ )
+ {
+ KeyManager = keyManager;
+ TransactionHashes = transactionHashes;
+ TransactionCache = new ConcurrentHashSet<SmartTransaction>();
+ Coins = coins;
+ DustThreshold = dustThreshold;
+ }
+
+ public bool Process(SmartTransaction tx)
+ {
+ uint256 txId = tx.GetHash();
+ var walletRelevant = false;
+
+ bool justUpdate = false;
+ if (tx.Confirmed)
+ {
+ TransactionHashes.TryRemove(txId); // If we have in mempool, remove.
+ if (!tx.Transaction.PossiblyP2WPKHInvolved())
+ {
+ return false; // We do not care about non-witness transactions for other than mempool cleanup.
+ }
+
+ bool isFoundTx = TransactionCache.Contains(tx); // If we have in cache, update height.
+ if (isFoundTx)
+ {
+ SmartTransaction foundTx = TransactionCache.FirstOrDefault(x => x == tx);
+ if (foundTx != default(SmartTransaction)) // Must check again, because it's a concurrent collection!
+ {
+ foundTx.SetHeight(tx.Height, tx.BlockHash, tx.BlockIndex);
+ walletRelevant = true;
+ justUpdate = true; // No need to check for double spend, we already processed this transaction, just update it.
+ }
+ }
+ }
+ else if (!tx.Transaction.PossiblyP2WPKHInvolved())
+ {
+ return false; // We do not care about non-witness transactions for other than mempool cleanup.
+ }
+
+ if (!justUpdate && !tx.Transaction.IsCoinBase) // Transactions we already have and processed would be "double spends" but they shouldn't.
+ {
+ var doubleSpends = new List<SmartCoin>();
+ foreach (SmartCoin coin in Coins)
+ {
+ var spent = false;
+ foreach (TxoRef spentOutput in coin.SpentOutputs)
+ {
+ foreach (TxIn txIn in tx.Transaction.Inputs)
+ {
+ if (spentOutput.TransactionId == txIn.PrevOut.Hash && spentOutput.Index == txIn.PrevOut.N) // Do not do (spentOutput == txIn.PrevOut), it's faster this way, because it won't check for null.
+ {
+ doubleSpends.Add(coin);
+ spent = true;
+ walletRelevant = true;
+ break;
+ }
+ }
+ if (spent)
+ {
+ break;
+ }
+ }
+ }
+
+ if (doubleSpends.Any())
+ {
+ if (tx.Height == Height.Mempool)
+ {
+ // if the received transaction is spending at least one input already
+ // spent by a previous unconfirmed transaction signaling RBF then it is not a double
+ // spanding transaction but a replacement transaction.
+ if (doubleSpends.Any(x => x.IsReplaceable))
+ {
+ // remove double spent coins (if other coin spends it, remove that too and so on)
+ // will add later if they came to our keys
+ foreach (SmartCoin doubleSpentCoin in doubleSpends.Where(x => !x.Confirmed))
+ {
+ Coins.TryRemove(doubleSpentCoin);
+ }
+ tx.SetReplacement();
+ walletRelevant = true;
+ }
+ else
+ {
+ return false;
+ }
+ }
+ else // new confirmation always enjoys priority
+ {
+ // remove double spent coins recursively (if other coin spends it, remove that too and so on), will add later if they came to our keys
+ foreach (SmartCoin doubleSpentCoin in doubleSpends)
+ {
+ Coins.TryRemove(doubleSpentCoin);
+ }
+ walletRelevant = true;
+ }
+ }
+ }
+
+ var isLikelyCoinJoinOutput = false;
+ bool hasEqualOutputs = tx.Transaction.GetIndistinguishableOutputs(includeSingle: false).FirstOrDefault() != default;
+ if (hasEqualOutputs)
+ {
+ var receiveKeys = KeyManager.GetKeys(x => tx.Transaction.Outputs.Any(y => y.ScriptPubKey == x.P2wpkhScript));
+ bool allReceivedInternal = receiveKeys.All(x => x.IsInternal);
+ if (allReceivedInternal)
+ {
+ // It is likely a coinjoin if the diff between receive and sent amount is small and have at least 2 equal outputs.
+ Money spentAmount = Coins.Where(x => tx.Transaction.Inputs.Any(y => y.PrevOut.Hash == x.TransactionId && y.PrevOut.N == x.Index)).Sum(x => x.Amount);
+ Money receivedAmount = tx.Transaction.Outputs.Where(x => receiveKeys.Any(y => y.P2wpkhScript == x.ScriptPubKey)).Sum(x => x.Value);
+ bool receivedAlmostAsMuchAsSpent = spentAmount.Almost(receivedAmount, Money.Coins(0.005m));
+
+ if (receivedAlmostAsMuchAsSpent)
+ {
+ isLikelyCoinJoinOutput = true;
+ }
+ }
+ }
+
+ List<SmartCoin> spentOwnCoins = null;
+ for (var i = 0U; i < tx.Transaction.Outputs.Count; i++)
+ {
+ // If transaction received to any of the wallet keys:
+ var output = tx.Transaction.Outputs[i];
+ HdPubKey foundKey = KeyManager.GetKeyForScriptPubKey(output.ScriptPubKey);
+ if (foundKey != default)
+ {
+ walletRelevant = true;
+
+ if (output.Value <= DustThreshold)
+ {
+ continue;
+ }
+
+ foundKey.SetKeyState(KeyState.Used, KeyManager);
+ spentOwnCoins = spentOwnCoins ?? Coins.Where(x => tx.Transaction.Inputs.Any(y => y.PrevOut.Hash == x.TransactionId && y.PrevOut.N == x.Index)).ToList();
+ var anonset = tx.Transaction.GetAnonymitySet(i);
+ if (spentOwnCoins.Count != 0)
+ {
+ anonset += spentOwnCoins.Min(x => x.AnonymitySet) - 1; // Minus 1, because do not count own.
+ }
+
+ SmartCoin newCoin = new SmartCoin(txId, i, output.ScriptPubKey, output.Value, tx.Transaction.Inputs.ToTxoRefs().ToArray(), tx.Height, tx.IsRBF, anonset, isLikelyCoinJoinOutput, foundKey.Label, spenderTransactionId: null, false, pubKey: foundKey); // Do not inherit locked status from key, that's different.
+ // If we did not have it.
+ if (Coins.TryAdd(newCoin))
+ {
+ TransactionCache.TryAdd(tx);
+ CoinReceived?.Invoke(this, newCoin);
+
+ // Make sure there's always 21 clean keys generated and indexed.
+ KeyManager.AssertCleanKeysIndexed(isInternal: foundKey.IsInternal);
+
+ if (foundKey.IsInternal)
+ {
+ // Make sure there's always 14 internal locked keys generated and indexed.
+ KeyManager.AssertLockedInternalKeysIndexed(14);
+ }
+ }
+ else // If we had this coin already.
+ {
+ if (newCoin.Height != Height.Mempool) // Update the height of this old coin we already had.
+ {
+ SmartCoin oldCoin = Coins.FirstOrDefault(x => x.TransactionId == txId && x.Index == i);
+ if (oldCoin != null) // Just to be sure, it is a concurrent collection.
+ {
+ oldCoin.Height = newCoin.Height;
+ }
+ }
+ }
+ }
+ }
+
+ // If spends any of our coin
+ for (var i = 0; i < tx.Transaction.Inputs.Count; i++)
+ {
+ var input = tx.Transaction.Inputs[i];
+
+ var foundCoin = Coins.FirstOrDefault(x => x.TransactionId == input.PrevOut.Hash && x.Index == input.PrevOut.N);
+ if (foundCoin != null)
+ {
+ walletRelevant = true;
+ foundCoin.SpenderTransactionId = txId;
+ TransactionCache.TryAdd(tx);
+ CoinSpentOrSpenderConfirmed?.Invoke(this, foundCoin);
+ }
+ }
+
+ return walletRelevant;
+ }
+ }
+}

MODIFY
WalletWasabi/Services/WalletService.cs
WalletWasabi/Services/WalletService.cs
@@ -57,7 +57,6 @@ namespace WalletWasabi.Services
public WasabiSynchronizer Synchronizer { get; }
public CcjClient ChaumianClient { get; }
public MempoolService Mempool { get; }
-
public NodesGroup Nodes { get; }
public string BlocksFolderPath { get; }
public string TransactionsFolderPath { get; }
@@ -75,16 +74,14 @@ namespace WalletWasabi.Services
public ObservableConcurrentHashSet<SmartCoin> Coins { get; }
- public ConcurrentHashSet<SmartTransaction> TransactionCache { get; }
-
public event EventHandler<FilterModel> NewFilterProcessed;
- public event EventHandler<SmartCoin> CoinSpentOrSpenderConfirmed;
-
public event EventHandler<Block> NewBlockProcessed;
public Network Network => Synchronizer.Network;
+ public TransactionProcessor TransactionProcessor { get; }
+
public WalletService(
BitcoinStore bitcoinStore,
KeyManager keyManager,
@@ -107,7 +104,6 @@ namespace WalletWasabi.Services
HandleFiltersLock = new AsyncLock();
Coins = new ObservableConcurrentHashSet<SmartCoin>();
- TransactionCache = new ConcurrentHashSet<SmartTransaction>();
BlocksFolderPath = Path.Combine(workFolderDir, "Blocks", Network.ToString());
TransactionsFolderPath = Path.Combine(workFolderDir, "Transactions", Network.ToString());
@@ -118,6 +114,10 @@ namespace WalletWasabi.Services
KeyManager.AssertCleanKeysIndexed();
KeyManager.AssertLockedInternalKeysIndexed(14);
+ TransactionProcessor = new TransactionProcessor(KeyManager, Mempool.TransactionHashes, Coins, ServiceConfiguration.DustThreshold);
+ TransactionProcessor.CoinSpentOrSpenderConfirmed += OnNewSpendindCoinFound;
+ TransactionProcessor.CoinReceived += OnNewCoinFound;
+
if (Directory.Exists(BlocksFolderPath))
{
if (Synchronizer.Network == Network.RegTest)
@@ -156,7 +156,30 @@ namespace WalletWasabi.Services
Mempool.TransactionReceived += Mempool_TransactionReceivedAsync;
}
- private void Coins_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
+ private void OnNewSpendindCoinFound(object sender, SmartCoin spentCoin)
+ {
+ ChaumianClient.ExposedLinks.TryRemove(spentCoin.GetTxoRef(), out _);
+ }
+
+ private async void OnNewCoinFound(object sender, SmartCoin newCoin)
+ {
+ // If it's being mixed and anonset is not sufficient, then queue it.
+ if (newCoin.Unspent && ChaumianClient.HasIngredients
+ && newCoin.AnonymitySet < ServiceConfiguration.MixUntilAnonymitySet
+ && ChaumianClient.State.Contains(newCoin.SpentOutputs))
+ {
+ try
+ {
+ await ChaumianClient.QueueCoinsToMixAsync(newCoin);
+ }
+ catch (Exception ex)
+ {
+ Logger.LogError<WalletService>(ex);
+ }
+ }
+ }
+
+ private void Coins_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
if(e.Action == NotifyCollectionChangedAction.Remove)
{
@@ -168,13 +191,14 @@ namespace WalletWasabi.Services
Coins.TryRemove(toAlsoRemove);
}
}
- Mempool.TransactionHashes.TryRemove(toRemove.TransactionId);
- var txToRemove = TransactionCache.FirstOrDefault(x=>x.GetHash() == toRemove.TransactionId);
+ Mempool.TransactionHashes.TryRemove(toRemove.TransactionId);
+ var txToRemove = TransactionProcessor.TransactionCache.FirstOrDefault(x=>x.GetHash() == toRemove.TransactionId);
if( txToRemove != default(SmartTransaction))
{
- TransactionCache.TryRemove(txToRemove);
+ TransactionProcessor.TransactionCache.TryRemove(txToRemove);
}
+
}
RefreshCoinHistories();
@@ -540,197 +564,7 @@ namespace WalletWasabi.Services
private async Task<bool> ProcessTransactionAsync(SmartTransaction tx)
{
- uint256 txId = tx.GetHash();
- var walletRelevant = false;
-
- bool justUpdate = false;
- if (tx.Confirmed)
- {
- Mempool.TransactionHashes.TryRemove(txId); // If we have in mempool, remove.
- if (!tx.Transaction.PossiblyP2WPKHInvolved())
- {
- return false; // We do not care about non-witness transactions for other than mempool cleanup.
- }
-
- bool isFoundTx = TransactionCache.Contains(tx); // If we have in cache, update height.
- if (isFoundTx)
- {
- SmartTransaction foundTx = TransactionCache.FirstOrDefault(x => x == tx);
- if (foundTx != default(SmartTransaction)) // Must check again, because it's a concurrent collection!
- {
- foundTx.SetHeight(tx.Height, tx.BlockHash, tx.BlockIndex);
- walletRelevant = true;
- justUpdate = true; // No need to check for double spend, we already processed this transaction, just update it.
- }
- }
- }
- else if (!tx.Transaction.PossiblyP2WPKHInvolved())
- {
- return false; // We do not care about non-witness transactions for other than mempool cleanup.
- }
-
- if (!justUpdate && !tx.Transaction.IsCoinBase) // Transactions we already have and processed would be "double spends" but they shouldn't.
- {
- var doubleSpends = new List<SmartCoin>();
- foreach (SmartCoin coin in Coins)
- {
- var spent = false;
- foreach (TxoRef spentOutput in coin.SpentOutputs)
- {
- foreach (TxIn txIn in tx.Transaction.Inputs)
- {
- if (spentOutput.TransactionId == txIn.PrevOut.Hash && spentOutput.Index == txIn.PrevOut.N) // Do not do (spentOutput == txIn.PrevOut), it's faster this way, because it won't check for null.
- {
- doubleSpends.Add(coin);
- spent = true;
- walletRelevant = true;
- break;
- }
- }
- if (spent)
- {
- break;
- }
- }
- }
-
- if (doubleSpends.Any())
- {
- if (tx.Height == Height.Mempool)
- {
- // if the received transaction is spending at least one input already
- // spent by a previous unconfirmed transaction signaling RBF then it is not a double
- // spanding transaction but a replacement transaction.
- if (doubleSpends.Any(x => x.IsReplaceable))
- {
- // remove double spent coins (if other coin spends it, remove that too and so on)
- // will add later if they came to our keys
- foreach (SmartCoin doubleSpentCoin in doubleSpends.Where(x => !x.Confirmed))
- {
- Coins.TryRemove(doubleSpentCoin);
- }
- tx.SetReplacement();
- walletRelevant = true;
- }
- else
- {
- return false;
- }
- }
- else // new confirmation always enjoys priority
- {
- // remove double spent coins recursively (if other coin spends it, remove that too and so on), will add later if they came to our keys
- foreach (SmartCoin doubleSpentCoin in doubleSpends)
- {
- Coins.TryRemove(doubleSpentCoin);
- }
- walletRelevant = true;
- }
- }
- }
-
- var isLikelyCoinJoinOutput = false;
- bool hasEqualOutputs = tx.Transaction.GetIndistinguishableOutputs(includeSingle: false).FirstOrDefault() != default;
- if (hasEqualOutputs)
- {
- var receiveKeys = KeyManager.GetKeys(x => tx.Transaction.Outputs.Any(y => y.ScriptPubKey == x.P2wpkhScript));
- bool allReceivedInternal = receiveKeys.All(x => x.IsInternal);
- if (allReceivedInternal)
- {
- // It is likely a coinjoin if the diff between receive and sent amount is small and have at least 2 equal outputs.
- Money spentAmount = Coins.Where(x => tx.Transaction.Inputs.Any(y => y.PrevOut.Hash == x.TransactionId && y.PrevOut.N == x.Index)).Sum(x => x.Amount);
- Money receivedAmount = tx.Transaction.Outputs.Where(x => receiveKeys.Any(y => y.P2wpkhScript == x.ScriptPubKey)).Sum(x => x.Value);
- bool receivedAlmostAsMuchAsSpent = spentAmount.Almost(receivedAmount, Money.Coins(0.005m));
-
- if (receivedAlmostAsMuchAsSpent)
- {
- isLikelyCoinJoinOutput = true;
- }
- }
- }
-
- List<SmartCoin> spentOwnCoins = null;
- for (var i = 0U; i < tx.Transaction.Outputs.Count; i++)
- {
- // If transaction received to any of the wallet keys:
- var output = tx.Transaction.Outputs[i];
- HdPubKey foundKey = KeyManager.GetKeyForScriptPubKey(output.ScriptPubKey);
- if (foundKey != default)
- {
- walletRelevant = true;
-
- foundKey.SetKeyState(KeyState.Used, KeyManager);
- if (output.Value <= ServiceConfiguration.DustThreshold)
- {
- continue;
- }
-
- spentOwnCoins = spentOwnCoins ?? Coins.Where(x => tx.Transaction.Inputs.Any(y => y.PrevOut.Hash == x.TransactionId && y.PrevOut.N == x.Index)).ToList();
- var anonset = tx.Transaction.GetAnonymitySet(i);
- if (spentOwnCoins.Count != 0)
- {
- anonset += spentOwnCoins.Min(x => x.AnonymitySet) - 1; // Minus 1, because do not count own.
- }
-
- SmartCoin newCoin = new SmartCoin(txId, i, output.ScriptPubKey, output.Value, tx.Transaction.Inputs.ToTxoRefs().ToArray(), tx.Height, tx.IsRBF, anonset, isLikelyCoinJoinOutput, foundKey.Label, spenderTransactionId: null, false, pubKey: foundKey); // Do not inherit locked status from key, that's different.
-
- if (Coins.TryAdd(newCoin))
- {
- TransactionCache.TryAdd(tx);
-
- // If it's being mixed and anonset is not sufficient, then queue it.
- if (newCoin.Unspent && ChaumianClient.HasIngredients && newCoin.AnonymitySet < ServiceConfiguration.MixUntilAnonymitySet && newCoin.IsLikelyCoinJoinOutput && ChaumianClient.State.Contains(newCoin.SpentOutputs))
- {
- try
- {
- await ChaumianClient.QueueCoinsToMixAsync(newCoin);
- }
- catch (Exception ex)
- {
- Logger.LogError<WalletService>(ex);
- }
- }
-
- // Make sure there's always 21 clean keys generated and indexed.
- KeyManager.AssertCleanKeysIndexed(isInternal: foundKey.IsInternal);
-
- if (foundKey.IsInternal)
- {
- // Make sure there's always 14 internal locked keys generated and indexed.
- KeyManager.AssertLockedInternalKeysIndexed(14);
- }
- }
- else // If we had this coin already.
- {
- if (newCoin.Height != Height.Mempool) // Update the height of this old coin we already had.
- {
- SmartCoin oldCoin = Coins.FirstOrDefault(x => x.TransactionId == txId && x.Index == i);
- if (oldCoin != null) // Just to be sure, it is a concurrent collection.
- {
- oldCoin.Height = newCoin.Height;
- }
- }
- }
- }
- }
-
- // If spends any of our coin
- for (var i = 0; i < tx.Transaction.Inputs.Count; i++)
- {
- var input = tx.Transaction.Inputs[i];
-
- var foundCoin = Coins.FirstOrDefault(x => x.TransactionId == input.PrevOut.Hash && x.Index == input.PrevOut.N);
- if (foundCoin != null)
- {
- walletRelevant = true;
- ChaumianClient.ExposedLinks.TryRemove(foundCoin.GetTxoRef(), out _);
- foundCoin.SpenderTransactionId = txId;
- TransactionCache.TryAdd(tx);
- CoinSpentOrSpenderConfirmed?.Invoke(this, foundCoin);
- }
- }
-
- return walletRelevant;
+ return await Task.FromResult(TransactionProcessor.Process(tx));
}
private Node _localBitcoinCoreNode = null;
@@ -1635,7 +1469,7 @@ namespace WalletWasabi.Services
}
IoHelpers.EnsureContainingDirectoryExists(TransactionsFilePath);
- string jsonString = JsonConvert.SerializeObject(TransactionCache.OrderByBlockchain(), Formatting.Indented);
+ string jsonString = JsonConvert.SerializeObject(TransactionProcessor.TransactionCache.OrderByBlockchain(), Formatting.Indented);
File.WriteAllText(TransactionsFilePath,
jsonString,
Encoding.UTF8);

Test transaction processing code (refactoring)

Detect coinjoin transactions