Skip to content

Commit

Permalink
Add manually_frozen tag (#393)
Browse files Browse the repository at this point in the history
* feat: addition of the tag manually_frozen

* test: fix and add more tests

* fix: linux compiler yelling at me
  • Loading branch information
markettes authored Sep 5, 2024
1 parent 5b4be97 commit a220b26
Show file tree
Hide file tree
Showing 5 changed files with 227 additions and 11 deletions.
1 change: 1 addition & 0 deletions src/Helpers/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 12 additions & 4 deletions src/Pages/Wallets.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down
4 changes: 1 addition & 3 deletions src/Rpc/NodeGuardService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -951,11 +951,9 @@ public override async Task<GetUtxosResponse> GetAvailableUtxos(GetAvailableUtxos
}

var lockedUtxos = await _fmutxoRepository.GetLockedUTXOs();
var frozenUtxos = await _utxoTagRepository.GetByKeyValue(Constants.IsFrozenTag, "true");

var ignoreOutpoints = new List<string>();
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);

Expand Down
27 changes: 25 additions & 2 deletions src/Services/CoinSelectionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ public interface ICoinSelectionService
/// <param name="bitcoinRequest"></param>
/// <param name="requestType"></param>
public Task<List<UTXO>> GetLockedUTXOsForRequest(IBitcoinRequest bitcoinRequest, BitcoinRequestType requestType);

/// <summary>
/// Gets the frozen UTXOs
/// </summary>
public Task<List<string>> GetFrozenUTXOs();

public Task<(List<ICoin> coins, List<UTXO> selectedUTXOs)> GetTxInputCoins(
List<UTXO> availableUTXOs,
Expand Down Expand Up @@ -146,9 +151,8 @@ public async Task<List<UTXO>> GetLockedUTXOsForRequest(IBitcoinRequest bitcoinRe
private async Task<List<UTXO>> 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<string>();
frozenAndLockedOutpoints.AddRange(listLocked);
frozenAndLockedOutpoints.AddRange(listFrozen);
Expand All @@ -171,6 +175,25 @@ private async Task<List<UTXO>> FilterLockedFrozenUTXOs(UTXOChanges? utxoChanges)

return availableUTXOs;
}

public async Task<List<string>> 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<string> frozenUTXOsList =
listFrozen
.Union(listManuallyFrozen)
.Except(listManuallyUnfrozen)
.ToList();

return frozenUTXOsList;
}

public async Task<List<UTXO>> GetAvailableUTXOsAsync(DerivationStrategyBase derivationStrategy)
{
Expand Down
190 changes: 188 additions & 2 deletions test/NodeGuard.Tests/Services/BitcoinServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using NBitcoin;
using NBXplorer.DerivationStrategy;
using NBXplorer.Models;
using NSubstitute;
using NSubstitute.Exceptions;
using Key = NodeGuard.Data.Models.Key;

Expand Down Expand Up @@ -402,7 +403,7 @@ async Task GenerateTemplatePSBT_SingleSigFailsFrozenUTXO()
.Setup(x => x.GetLockedUTXOs(null, null))
.ReturnsAsync(new List<FMUTXO>());
utxoTagRepository
.Setup(x => x.GetByKeyValue(It.IsAny<string>(), It.IsAny<string>()))
.SetupSequence(x => x.GetByKeyValue(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new List<UTXOTag>()
{
new UTXOTag()
Expand All @@ -411,7 +412,9 @@ async Task GenerateTemplatePSBT_SingleSigFailsFrozenUTXO()
Value = "true",
Outpoint = "00000000000000000000000000000000000000000000000000000000000004d2-1"
}
});
})
.ReturnsAsync(new List<UTXOTag>())
.ReturnsAsync(new List<UTXOTag>());

var coinSelectionService = new CoinSelectionService(_logger, mapper.Object, fmutxoRepository.Object, nbXplorerService.Object, null, walletWithdrawalRequestRepository.Object, utxoTagRepository.Object);

Expand All @@ -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<WalletWithdrawalRequestPSBT>(),
Amount = 0.01m,
DestinationAddress = "bcrt1q8k3av6q5yp83rn332lx8a90k6kukhg28hs5qw7krdq95t629hgsqk6ztmf"
};

var walletWithdrawalRequestRepository = new Mock<IWalletWithdrawalRequestRepository>();
var walletWithdrawalRequestPsbtRepository = new Mock<IWalletWithdrawalRequestPsbtRepository>();
var fmutxoRepository = new Mock<IFMUTXORepository>();
var nbXplorerService = new Mock<INBXplorerService>();
var utxoTagRepository = new Mock<IUTXOTagRepository>();
var mapper = new Mock<IMapper>();
walletWithdrawalRequestRepository
.Setup((w) => w.GetById(It.IsAny<int>()))
.ReturnsAsync(withdrawalRequest);
walletWithdrawalRequestRepository
.Setup((w) => w.AddUTXOs(It.IsAny<WalletWithdrawalRequest>(), It.IsAny<List<FMUTXO>>()))
.ReturnsAsync((true, null));
walletWithdrawalRequestPsbtRepository
.Setup((w) => w.AddAsync(It.IsAny<WalletWithdrawalRequestPSBT>()))
.ReturnsAsync((true, null));
nbXplorerService
.Setup(x => x.GetStatusAsync(default))
.ReturnsAsync(new StatusResult() { IsFullySynched = true });
nbXplorerService
.Setup(x => x.GetUnusedAsync(It.IsAny<DerivationStrategyBase>(), DerivationFeature.Change, 0, false, default))
.ReturnsAsync(new KeyPathInformation() { Address = BitcoinAddress.Create("bcrt1q83ml8tve8vh672wsm83getxfzetaquq352jr6t423tdwjvdz3f3qe4r4t7", Network.RegTest) });
nbXplorerService
.Setup(x => x.GetUTXOsAsync(It.IsAny<DerivationStrategyBase>(), default))
.ReturnsAsync(new UTXOChanges()
{
Confirmed = new UTXOChange()
{
UTXOs = new List<UTXO>()
{
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<FMUTXO>());
utxoTagRepository
.SetupSequence(x => x.GetByKeyValue(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new List<UTXOTag>()
{
new UTXOTag()
{
Key = Constants.IsFrozenTag,
Value = "false",
Outpoint = "00000000000000000000000000000000000000000000000000000000000004d2-1"
}
})
.ReturnsAsync(new List<UTXOTag>())
.ReturnsAsync(new List<UTXOTag>()
{
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<WalletWithdrawalRequestPSBT>(),
Amount = 0.01m,
DestinationAddress = "bcrt1q8k3av6q5yp83rn332lx8a90k6kukhg28hs5qw7krdq95t629hgsqk6ztmf"
};

var walletWithdrawalRequestRepository = new Mock<IWalletWithdrawalRequestRepository>();
var walletWithdrawalRequestPsbtRepository = new Mock<IWalletWithdrawalRequestPsbtRepository>();
var fmutxoRepository = new Mock<IFMUTXORepository>();
var nbXplorerService = new Mock<INBXplorerService>();
var utxoTagRepository = new Mock<IUTXOTagRepository>();
var mapper = new Mock<IMapper>();
walletWithdrawalRequestRepository
.Setup((w) => w.GetById(It.IsAny<int>()))
.ReturnsAsync(withdrawalRequest);
walletWithdrawalRequestRepository
.Setup((w) => w.AddUTXOs(It.IsAny<WalletWithdrawalRequest>(), It.IsAny<List<FMUTXO>>()))
.ReturnsAsync((true, null));
walletWithdrawalRequestPsbtRepository
.Setup((w) => w.AddAsync(It.IsAny<WalletWithdrawalRequestPSBT>()))
.ReturnsAsync((true, null));
nbXplorerService
.Setup(x => x.GetStatusAsync(default))
.ReturnsAsync(new StatusResult() { IsFullySynched = true });
nbXplorerService
.Setup(x => x.GetUnusedAsync(It.IsAny<DerivationStrategyBase>(), DerivationFeature.Change, 0, false, default))
.ReturnsAsync(new KeyPathInformation() { Address = BitcoinAddress.Create("bcrt1q83ml8tve8vh672wsm83getxfzetaquq352jr6t423tdwjvdz3f3qe4r4t7", Network.RegTest) });
nbXplorerService
.Setup(x => x.GetUTXOsAsync(It.IsAny<DerivationStrategyBase>(), default))
.ReturnsAsync(new UTXOChanges()
{
Confirmed = new UTXOChange()
{
UTXOs = new List<UTXO>()
{
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<FMUTXO>());
utxoTagRepository
.SetupSequence(x => x.GetByKeyValue(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new List<UTXOTag>()
{
new UTXOTag()
{
Key = Constants.IsFrozenTag,
Value = "false",
Outpoint = "00000000000000000000000000000000000000000000000000000000000004d2-1"
}
})
.ReturnsAsync(new List<UTXOTag>()
{
new UTXOTag()
{
Key = Constants.IsManuallyFrozenTag,
Value = "true",
Outpoint = "00000000000000000000000000000000000000000000000000000000000004d2-1"
}
})
.ReturnsAsync(new List<UTXOTag>());
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<NoUTXOsAvailableException>()
.WithMessage("Exception of type 'NodeGuard.Helpers.NoUTXOsAvailableException' was thrown.");
}

[Fact]
async Task GenerateTemplatePSBT_Changeless_SingleSigSucceeds()
{
Expand Down

0 comments on commit a220b26

Please sign in to comment.