diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index 5438e611..046ec18c 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -87,6 +87,7 @@ public class Constants public static decimal MAX_TX_FEE_RATIO = 0.5m; public const string IsFrozenTag = "frozen"; + public const string IsManuallyFrozenTag = "manually_frozen"; // Constants for the NBXplorer API public static int SCAN_GAP_LIMIT = 1000; diff --git a/src/Pages/Wallets.razor b/src/Pages/Wallets.razor index 149e3a84..62ad138c 100644 --- a/src/Pages/Wallets.razor +++ b/src/Pages/Wallets.razor @@ -1154,8 +1154,16 @@ .Select((u) => ( u.Outpoint, u, - tagsMap[u.Outpoint].Where(t => t.Key != Constants.IsFrozenTag).ToList(), - tagsMap[u.Outpoint].Any(t => t.Key == Constants.IsFrozenTag && t.Value == "true") + tagsMap[u.Outpoint] + .Where(t => t.Key != Constants.IsFrozenTag && t.Key != Constants.IsManuallyFrozenTag) + .ToList(), + // Check if the UTXO is frozen, has been manually frozen or has been manually unfrozen + (tagsMap[u.Outpoint] + .Any(t => t.Key == Constants.IsFrozenTag && t.Value == "true") || + tagsMap[u.Outpoint] + .Any(t => t.Key == Constants.IsManuallyFrozenTag && t.Value == "true")) && + !tagsMap[u.Outpoint] + .Any(t => t.Key == Constants.IsManuallyFrozenTag && t.Value == "false") )).ToList(); _detailsTransactions = await NBXplorerService.GetTransactionsAsync(derivationStrategyBase); @@ -1411,7 +1419,7 @@ var withdrawalRequest = new WalletWithdrawalRequest { UserRequestorId = LoggedUser != null ? LoggedUser.Id : string.Empty, - Description = @$"Funds transferred from {_sourceWalletName} to {_targetWalletName}", + Description = $"Funds transferred from {_sourceWalletName} to {_targetWalletName}", WithdrawAllFunds = _transferAllFunds, DestinationAddress = targetBitcoinAddress.ToString(), MempoolRecommendedFeesType = MempoolRecommendedFeesType.HourFee, @@ -1703,8 +1711,8 @@ private async Task ToggleUtxoFreeze(bool newValue, UTXO utxo) { - var tag = await UTXOTagRepository.GetByKeyAndOutpoint(Constants.IsFrozenTag, utxo.Outpoint.ToString()); - var (saved, _) = UTXOTagRepository.Update(new UTXOTag {Key = Constants.IsFrozenTag, Value = newValue ? "true" : "false", Outpoint = utxo.Outpoint.ToString(), Id = tag?.Id ?? 0 }); + var tag = await UTXOTagRepository.GetByKeyAndOutpoint(Constants.IsManuallyFrozenTag, utxo.Outpoint.ToString()); + var (saved, _) = UTXOTagRepository.Update(new UTXOTag {Key = Constants.IsManuallyFrozenTag, Value = newValue ? "true" : "false", Outpoint = utxo.Outpoint.ToString(), Id = tag?.Id ?? 0 }); if (!saved) { ToastService.ShowError("Error while updating the UTXO status"); diff --git a/src/Rpc/NodeGuardService.cs b/src/Rpc/NodeGuardService.cs index 6069f35f..52d96fd9 100644 --- a/src/Rpc/NodeGuardService.cs +++ b/src/Rpc/NodeGuardService.cs @@ -951,11 +951,9 @@ public override async Task GetAvailableUtxos(GetAvailableUtxos } var lockedUtxos = await _fmutxoRepository.GetLockedUTXOs(); - var frozenUtxos = await _utxoTagRepository.GetByKeyValue(Constants.IsFrozenTag, "true"); - var ignoreOutpoints = new List(); var listLocked = lockedUtxos.Select(utxo => $"{utxo.TxId}-{utxo.OutputIndex}").ToList(); - var listFrozen = frozenUtxos.Select(utxo => utxo.Outpoint).ToList(); + var listFrozen = await _coinSelectionService.GetFrozenUTXOs(); ignoreOutpoints.AddRange(listLocked); ignoreOutpoints.AddRange(listFrozen); diff --git a/src/Services/CoinSelectionService.cs b/src/Services/CoinSelectionService.cs index 5042d89b..ad2daee1 100644 --- a/src/Services/CoinSelectionService.cs +++ b/src/Services/CoinSelectionService.cs @@ -67,6 +67,11 @@ public interface ICoinSelectionService /// /// public Task> GetLockedUTXOsForRequest(IBitcoinRequest bitcoinRequest, BitcoinRequestType requestType); + + /// + /// Gets the frozen UTXOs + /// + public Task> GetFrozenUTXOs(); public Task<(List coins, List selectedUTXOs)> GetTxInputCoins( List availableUTXOs, @@ -146,9 +151,8 @@ public async Task> GetLockedUTXOsForRequest(IBitcoinRequest bitcoinRe private async Task> FilterLockedFrozenUTXOs(UTXOChanges? utxoChanges) { var lockedUTXOs = await _fmutxoRepository.GetLockedUTXOs(); - var frozenUTXOs = await _utxoTagRepository.GetByKeyValue(Constants.IsFrozenTag, "true"); var listLocked = lockedUTXOs.Select(utxo => $"{utxo.TxId}-{utxo.OutputIndex}").ToList(); - var listFrozen = frozenUTXOs.Select(utxo => utxo.Outpoint).ToList(); + var listFrozen = await GetFrozenUTXOs(); var frozenAndLockedOutpoints = new List(); frozenAndLockedOutpoints.AddRange(listLocked); frozenAndLockedOutpoints.AddRange(listFrozen); @@ -171,6 +175,25 @@ private async Task> FilterLockedFrozenUTXOs(UTXOChanges? utxoChanges) return availableUTXOs; } + + public async Task> GetFrozenUTXOs() + { + var frozenUTXOs = await _utxoTagRepository.GetByKeyValue(Constants.IsFrozenTag, "true"); + var manuallyFrozenUTXOs = await _utxoTagRepository.GetByKeyValue(Constants.IsManuallyFrozenTag, "true"); + var manuallyUnfrozenUTXOs = await _utxoTagRepository.GetByKeyValue(Constants.IsManuallyFrozenTag, "false"); + var listFrozen = frozenUTXOs.Select(utxo => utxo.Outpoint).ToList(); + var listManuallyFrozen = manuallyFrozenUTXOs.Select(utxo => utxo.Outpoint).ToList(); + var listManuallyUnfrozen = manuallyUnfrozenUTXOs.Select(utxo => utxo.Outpoint).ToList(); + + // Merge manually frozen and frozen UTXOs and remove manually unfrozen UTXOs + List frozenUTXOsList = + listFrozen + .Union(listManuallyFrozen) + .Except(listManuallyUnfrozen) + .ToList(); + + return frozenUTXOsList; + } public async Task> GetAvailableUTXOsAsync(DerivationStrategyBase derivationStrategy) { diff --git a/test/NodeGuard.Tests/Services/BitcoinServiceTests.cs b/test/NodeGuard.Tests/Services/BitcoinServiceTests.cs index 9766a404..88c68288 100644 --- a/test/NodeGuard.Tests/Services/BitcoinServiceTests.cs +++ b/test/NodeGuard.Tests/Services/BitcoinServiceTests.cs @@ -8,6 +8,7 @@ using NBitcoin; using NBXplorer.DerivationStrategy; using NBXplorer.Models; +using NSubstitute; using NSubstitute.Exceptions; using Key = NodeGuard.Data.Models.Key; @@ -402,7 +403,7 @@ async Task GenerateTemplatePSBT_SingleSigFailsFrozenUTXO() .Setup(x => x.GetLockedUTXOs(null, null)) .ReturnsAsync(new List()); utxoTagRepository - .Setup(x => x.GetByKeyValue(It.IsAny(), It.IsAny())) + .SetupSequence(x => x.GetByKeyValue(It.IsAny(), It.IsAny())) .ReturnsAsync(new List() { new UTXOTag() @@ -411,7 +412,9 @@ async Task GenerateTemplatePSBT_SingleSigFailsFrozenUTXO() Value = "true", Outpoint = "00000000000000000000000000000000000000000000000000000000000004d2-1" } - }); + }) + .ReturnsAsync(new List()) + .ReturnsAsync(new List()); var coinSelectionService = new CoinSelectionService(_logger, mapper.Object, fmutxoRepository.Object, nbXplorerService.Object, null, walletWithdrawalRequestRepository.Object, utxoTagRepository.Object); @@ -427,6 +430,189 @@ await act .WithMessage("Exception of type 'NodeGuard.Helpers.NoUTXOsAvailableException' was thrown."); } + [Fact] + async Task GenerateTemplatePSBT_SingleSigSuccessManuallyUnfrozenUTXO() + { + // Arrange + var wallet = CreateWallet.SingleSig(_internalWallet); + var withdrawalRequest = new WalletWithdrawalRequest() + { + Id = 1, + Status = WalletWithdrawalRequestStatus.Pending, + Wallet = wallet, + WalletWithdrawalRequestPSBTs = new List(), + Amount = 0.01m, + DestinationAddress = "bcrt1q8k3av6q5yp83rn332lx8a90k6kukhg28hs5qw7krdq95t629hgsqk6ztmf" + }; + + var walletWithdrawalRequestRepository = new Mock(); + var walletWithdrawalRequestPsbtRepository = new Mock(); + var fmutxoRepository = new Mock(); + var nbXplorerService = new Mock(); + var utxoTagRepository = new Mock(); + var mapper = new Mock(); + walletWithdrawalRequestRepository + .Setup((w) => w.GetById(It.IsAny())) + .ReturnsAsync(withdrawalRequest); + walletWithdrawalRequestRepository + .Setup((w) => w.AddUTXOs(It.IsAny(), It.IsAny>())) + .ReturnsAsync((true, null)); + walletWithdrawalRequestPsbtRepository + .Setup((w) => w.AddAsync(It.IsAny())) + .ReturnsAsync((true, null)); + nbXplorerService + .Setup(x => x.GetStatusAsync(default)) + .ReturnsAsync(new StatusResult() { IsFullySynched = true }); + nbXplorerService + .Setup(x => x.GetUnusedAsync(It.IsAny(), DerivationFeature.Change, 0, false, default)) + .ReturnsAsync(new KeyPathInformation() { Address = BitcoinAddress.Create("bcrt1q83ml8tve8vh672wsm83getxfzetaquq352jr6t423tdwjvdz3f3qe4r4t7", Network.RegTest) }); + nbXplorerService + .Setup(x => x.GetUTXOsAsync(It.IsAny(), default)) + .ReturnsAsync(new UTXOChanges() + { + Confirmed = new UTXOChange() + { + UTXOs = new List() + { + new UTXO() + { + Outpoint = new OutPoint(1234, 1), + Value = new Money((long)10000000), + ScriptPubKey = wallet.GetDerivationStrategy().GetDerivation(KeyPath.Parse("0/0")).ScriptPubKey, + KeyPath = KeyPath.Parse("0/0") + } + } + } + }); + fmutxoRepository + .Setup(x => x.GetLockedUTXOs(null, null)) + .ReturnsAsync(new List()); + utxoTagRepository + .SetupSequence(x => x.GetByKeyValue(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List() + { + new UTXOTag() + { + Key = Constants.IsFrozenTag, + Value = "false", + Outpoint = "00000000000000000000000000000000000000000000000000000000000004d2-1" + } + }) + .ReturnsAsync(new List()) + .ReturnsAsync(new List() + { + new UTXOTag() + { + Key = Constants.IsManuallyFrozenTag, + Value = "true", + Outpoint = "00000000000000000000000000000000000000000000000000000000000004d2-1" + } + }); + + var coinSelectionService = new CoinSelectionService(_logger, mapper.Object, fmutxoRepository.Object, nbXplorerService.Object, null, walletWithdrawalRequestRepository.Object, utxoTagRepository.Object); + + var bitcoinService = new BitcoinService(_logger, mapper.Object, walletWithdrawalRequestRepository.Object, walletWithdrawalRequestPsbtRepository.Object, null, null, nbXplorerService.Object, coinSelectionService); + + // Act + var result = await bitcoinService.GenerateTemplatePSBT(withdrawalRequest); + + // Assert + var psbt = PSBT.Parse("cHNidP8BAIkBAAAAAdIEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAD/////AkBCDwAAAAAAIgAgPaPWaBQgTxHOMVfMfpX21blroUe8KAd6w2gLRelFuiCsUYkAAAAAACIAIDx3862ZOy+vKdDZ4oysyRZX0HARoqQ9LqqK2ukxoopiAAAAAE8BBDWHzwN9uUaNAAAAAYPR/OiA1LbTzxbLPvbXvtAwckIG3g+0T1zblR/ZodaiA5zBFsigPpL8htN/KJ/Ph8SPvQA/K+mSNXTSA0hgvPNuEO0CEMgwAACAAQAAgAEAAAAAAQEfgJaYAAAAAAAWABTpOvUBMqNMfl7P81etji6x4fXrMyIGA3uD9HVjgF5E+eQhHp+Na6femVYpc4bCA4DmimehAdWcGO0CEMgwAACAAQAAgAEAAAAAAAAAAAAAAAAAAA==", Network.RegTest); + result.Should().BeEquivalentTo(psbt); + } + + [Fact] + async Task GenerateTemplatePSBT_SingleSigFailsManuallyFrozenUTXO() + { + // Arrange + var wallet = CreateWallet.SingleSig(_internalWallet); + var withdrawalRequest = new WalletWithdrawalRequest() + { + Id = 1, + Status = WalletWithdrawalRequestStatus.Pending, + Wallet = wallet, + WalletWithdrawalRequestPSBTs = new List(), + Amount = 0.01m, + DestinationAddress = "bcrt1q8k3av6q5yp83rn332lx8a90k6kukhg28hs5qw7krdq95t629hgsqk6ztmf" + }; + + var walletWithdrawalRequestRepository = new Mock(); + var walletWithdrawalRequestPsbtRepository = new Mock(); + var fmutxoRepository = new Mock(); + var nbXplorerService = new Mock(); + var utxoTagRepository = new Mock(); + var mapper = new Mock(); + walletWithdrawalRequestRepository + .Setup((w) => w.GetById(It.IsAny())) + .ReturnsAsync(withdrawalRequest); + walletWithdrawalRequestRepository + .Setup((w) => w.AddUTXOs(It.IsAny(), It.IsAny>())) + .ReturnsAsync((true, null)); + walletWithdrawalRequestPsbtRepository + .Setup((w) => w.AddAsync(It.IsAny())) + .ReturnsAsync((true, null)); + nbXplorerService + .Setup(x => x.GetStatusAsync(default)) + .ReturnsAsync(new StatusResult() { IsFullySynched = true }); + nbXplorerService + .Setup(x => x.GetUnusedAsync(It.IsAny(), DerivationFeature.Change, 0, false, default)) + .ReturnsAsync(new KeyPathInformation() { Address = BitcoinAddress.Create("bcrt1q83ml8tve8vh672wsm83getxfzetaquq352jr6t423tdwjvdz3f3qe4r4t7", Network.RegTest) }); + nbXplorerService + .Setup(x => x.GetUTXOsAsync(It.IsAny(), default)) + .ReturnsAsync(new UTXOChanges() + { + Confirmed = new UTXOChange() + { + UTXOs = new List() + { + new UTXO() + { + Outpoint = new OutPoint(1234, 1), + Value = new Money((long)10000000), + ScriptPubKey = wallet.GetDerivationStrategy().GetDerivation(KeyPath.Parse("0/0")).ScriptPubKey, + KeyPath = KeyPath.Parse("0/0") + } + } + } + }); + fmutxoRepository + .Setup(x => x.GetLockedUTXOs(null, null)) + .ReturnsAsync(new List()); + utxoTagRepository + .SetupSequence(x => x.GetByKeyValue(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List() + { + new UTXOTag() + { + Key = Constants.IsFrozenTag, + Value = "false", + Outpoint = "00000000000000000000000000000000000000000000000000000000000004d2-1" + } + }) + .ReturnsAsync(new List() + { + new UTXOTag() + { + Key = Constants.IsManuallyFrozenTag, + Value = "true", + Outpoint = "00000000000000000000000000000000000000000000000000000000000004d2-1" + } + }) + .ReturnsAsync(new List()); + var coinSelectionService = new CoinSelectionService(_logger, mapper.Object, fmutxoRepository.Object, nbXplorerService.Object, null, walletWithdrawalRequestRepository.Object, utxoTagRepository.Object); + + var bitcoinService = new BitcoinService(_logger, mapper.Object, walletWithdrawalRequestRepository.Object, walletWithdrawalRequestPsbtRepository.Object, null, null, nbXplorerService.Object, coinSelectionService); + + // Act + var act = () => bitcoinService.GenerateTemplatePSBT(withdrawalRequest); + + // Assert + await act + .Should() + .ThrowAsync() + .WithMessage("Exception of type 'NodeGuard.Helpers.NoUTXOsAvailableException' was thrown."); + } + [Fact] async Task GenerateTemplatePSBT_Changeless_SingleSigSucceeds() {