diff --git a/src/Data/Models/ChannelOperationRequest.cs b/src/Data/Models/ChannelOperationRequest.cs index 3b2e8018..56d41fcf 100644 --- a/src/Data/Models/ChannelOperationRequest.cs +++ b/src/Data/Models/ChannelOperationRequest.cs @@ -135,6 +135,11 @@ public decimal Amount /// public string? JobId { get; set; } + /// + /// This indicates if the user requested a changeless operation by selecting UTXOs + /// + public bool Changeless { get; set; } + /// /// Check that the number of signatures (not finalised psbt nor internal wallet psbt or template psbt are gathered and increases by one to count on the internal wallet signature /// diff --git a/src/Data/Repositories/ChannelOperationRequestRepository.cs b/src/Data/Repositories/ChannelOperationRequestRepository.cs index 55af0f12..d7f4e2ab 100644 --- a/src/Data/Repositories/ChannelOperationRequestRepository.cs +++ b/src/Data/Repositories/ChannelOperationRequestRepository.cs @@ -193,6 +193,28 @@ public async Task> GetUnsignedPendingRequestsByUse return result; } + public async Task<(bool, List?)> GetUTXOs(IBitcoinRequest request) + { + await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync(); + + (bool, List?) result = (true, null); + try + { + var channelOperationRequest = await applicationDbContext.ChannelOperationRequests + .Include(r => r.Utxos) + .FirstOrDefaultAsync(r => r.Id == request.Id); + + result.Item2 = channelOperationRequest.Utxos; + } + catch (Exception e) + { + _logger.LogError(e, "Error while getting UTXOs from channel op request: {RequestId}", request.Id); + result.Item1 = false; + } + + return result; + } + public async Task> GetPendingRequests() { await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync(); diff --git a/src/Data/Repositories/Interfaces/IBitcoinRequestRepository.cs b/src/Data/Repositories/Interfaces/IBitcoinRequestRepository.cs index f4a77c08..4f56b789 100644 --- a/src/Data/Repositories/Interfaces/IBitcoinRequestRepository.cs +++ b/src/Data/Repositories/Interfaces/IBitcoinRequestRepository.cs @@ -30,4 +30,6 @@ public interface IBitcoinRequestRepository /// /// Task<(bool, string?)> AddUTXOs(IBitcoinRequest type, List utxos); + + public Task<(bool, List?)> GetUTXOs(IBitcoinRequest type); } \ No newline at end of file diff --git a/src/Data/Repositories/Interfaces/IChannelOperationRequestRepository.cs b/src/Data/Repositories/Interfaces/IChannelOperationRequestRepository.cs index 7440d9b2..64f56b71 100644 --- a/src/Data/Repositories/Interfaces/IChannelOperationRequestRepository.cs +++ b/src/Data/Repositories/Interfaces/IChannelOperationRequestRepository.cs @@ -39,14 +39,6 @@ public interface IChannelOperationRequestRepository : IBitcoinRequestRepository (bool, string?) Update(ChannelOperationRequest type); - /// - /// Adds on the many-to-many collection the list of utxos provided - /// - /// - /// - /// - Task<(bool, string?)> AddUTXOs(IBitcoinRequest type, List utxos); - /// /// Returns those requests that can have a PSBT locked until they are confirmed / rejected / cancelled /// diff --git a/src/Data/Repositories/Interfaces/IWalletWithdrawalRequestRepository.cs b/src/Data/Repositories/Interfaces/IWalletWithdrawalRequestRepository.cs index 271d3cf5..fd5e4348 100644 --- a/src/Data/Repositories/Interfaces/IWalletWithdrawalRequestRepository.cs +++ b/src/Data/Repositories/Interfaces/IWalletWithdrawalRequestRepository.cs @@ -21,7 +21,7 @@ namespace FundsManager.Data.Repositories.Interfaces; -public interface IWalletWithdrawalRequestRepository +public interface IWalletWithdrawalRequestRepository: IBitcoinRequestRepository { Task GetById(int id); @@ -39,8 +39,6 @@ public interface IWalletWithdrawalRequestRepository (bool, string?) Update(WalletWithdrawalRequest type); - Task<(bool, string?)> AddUTXOs(WalletWithdrawalRequest type, List utxos); - Task> GetPendingRequests(); Task> GetOnChainPendingWithdrawals(); diff --git a/src/Data/Repositories/WalletWithdrawalRequestRepository.cs b/src/Data/Repositories/WalletWithdrawalRequestRepository.cs index 0ce70244..e97dfb08 100644 --- a/src/Data/Repositories/WalletWithdrawalRequestRepository.cs +++ b/src/Data/Repositories/WalletWithdrawalRequestRepository.cs @@ -24,6 +24,7 @@ using Humanizer; using Microsoft.EntityFrameworkCore; using NBitcoin; +using Exception = System.Exception; namespace FundsManager.Data.Repositories { @@ -38,8 +39,8 @@ public class WalletWithdrawalRequestRepository : IWalletWithdrawalRequestReposit public WalletWithdrawalRequestRepository(IRepository repository, ILogger logger, - IDbContextFactory dbContextFactory, - IMapper mapper, + IDbContextFactory dbContextFactory, + IMapper mapper, NotificationService notificationService, INBXplorerService nBXplorerService ) @@ -101,22 +102,22 @@ public async Task> GetUnsignedPendingRequestsByUse type.SetCreationDatetime(); type.SetUpdateDatetime(); - + //Verify that the wallet has enough funds calling nbxplorer var wallet = await applicationDbContext.Wallets.Include(x=> x.Keys).SingleOrDefaultAsync(x => x.Id == type.WalletId); - + if (wallet == null) { return (false, "The wallet could not be found."); } var derivationStrategyBase = wallet.GetDerivationStrategy(); - + if (derivationStrategyBase == null) { return (false, "The wallet does not have a derivation strategy."); } - + var balance = await _nBXplorerService.GetBalanceAsync(derivationStrategyBase, default); if (balance == null) @@ -125,12 +126,12 @@ public async Task> GetUnsignedPendingRequestsByUse } var requestMoneyAmount = new Money(type.Amount, MoneyUnit.BTC); - + if ((Money) balance.Confirmed < requestMoneyAmount) { return (false, $"The wallet {type.Wallet.Name} does not have enough funds to complete this withdrawal request. The wallet has {balance.Confirmed} BTC and the withdrawal request is for {requestMoneyAmount} BTC."); } - + var valueTuple = await _repository.AddAsync(type, applicationDbContext); if (!wallet.IsHotWallet) @@ -171,7 +172,7 @@ public async Task> GetUnsignedPendingRequestsByUse return _repository.Update(strippedType, applicationDbContext); } - public async Task<(bool, string?)> AddUTXOs(WalletWithdrawalRequest type, List utxos) + public async Task<(bool, string?)> AddUTXOs(IBitcoinRequest type, List utxos) { if (type == null) throw new ArgumentNullException(nameof(type)); if (utxos.Count == 0) throw new ArgumentException("Value cannot be an empty collection.", nameof(utxos)); @@ -210,6 +211,28 @@ public async Task> GetUnsignedPendingRequestsByUse return result; } + public async Task<(bool, List?)> GetUTXOs(IBitcoinRequest request) + { + await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync(); + + (bool, List?) result = (true, null); + try + { + var walletWithdrawalRequest = await applicationDbContext.WalletWithdrawalRequests + .Include(r => r.UTXOs) + .FirstOrDefaultAsync(r => r.Id == request.Id); + + result.Item2 = walletWithdrawalRequest.UTXOs; + } + catch (Exception e) + { + _logger.LogError(e, "Error while getting UTXOs from wallet withdrawal request: {RequestId}", request.Id); + result.Item1 = false; + } + + return result; + } + public async Task> GetPendingRequests() { await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync(); diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index 6da2f806..527edaf9 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -27,6 +27,7 @@ public class Constants public static readonly bool ENABLE_REMOTE_SIGNER; public static readonly bool PUSH_NOTIFICATIONS_ONESIGNAL_ENABLED; public static readonly bool ENABLE_HW_SUPPORT; + public static readonly bool FEE_SELECTION_ENABLED = false; // Incomplete feature, remove this line when it's ready // Connections public static readonly string POSTGRES_CONNECTIONSTRING = "Host=localhost;Port=5432;Database=fundsmanager;Username=rw_dev;Password=rw_dev"; @@ -62,6 +63,7 @@ public class Constants // Usage public static readonly string BITCOIN_NETWORK; public static readonly long MINIMUM_CHANNEL_CAPACITY_SATS = 20_000; + public static readonly long MAXIMUM_CHANNEL_CAPACITY_SATS_REGTEST = 16_777_215; public static readonly decimal MINIMUM_WITHDRAWAL_BTC_AMOUNT = 0.0m; public static readonly decimal MAXIMUM_WITHDRAWAL_BTC_AMOUNT = 21_000_000; public static readonly int TRANSACTION_CONFIRMATION_MINIMUM_BLOCKS; @@ -102,7 +104,7 @@ static Constants() ALICE_HOST = Environment.GetEnvironmentVariable("ALICE_HOST") ?? "host.docker.internal:10001"; CAROL_HOST = Environment.GetEnvironmentVariable("CAROL_HOST") ?? "host.docker.internal:10003"; - + BOB_HOST = Environment.GetEnvironmentVariable("BOB_HOST") ?? "host.docker.internal:10002"; FUNDSMANAGER_ENDPOINT = Environment.GetEnvironmentVariable("FUNDSMANAGER_ENDPOINT"); @@ -136,7 +138,7 @@ static Constants() AWS_ACCESS_KEY_ID = GetEnvironmentalVariableOrThrowIfNotTesting("AWS_ACCESS_KEY_ID", "if ENABLE_REMOTE_SIGNER is set, AWS_ACCESS_KEY_ID"); AWS_SECRET_ACCESS_KEY = GetEnvironmentalVariableOrThrowIfNotTesting("AWS_SECRET_ACCESS_KEY", "if ENABLE_REMOTE_SIGNER is set, AWS_SECRET_ACCESS_KEY"); - + REMOTE_SIGNER_ENDPOINT = GetEnvironmentalVariableOrThrowIfNotTesting("REMOTE_SIGNER_ENDPOINT", "if ENABLE_REMOTE_SIGNER is set, REMOTE_SIGNER_ENDPOINT"); } diff --git a/src/Helpers/LightningHelper.cs b/src/Helpers/LightningHelper.cs index 87d3d616..9e972330 100644 --- a/src/Helpers/LightningHelper.cs +++ b/src/Helpers/LightningHelper.cs @@ -64,10 +64,14 @@ public static void RemoveDuplicateUTXOs(this UTXOChanges utxoChanges) var rootedKeyPath = key.GetRootedKeyPath(); //Global xpubs field addition - result.GlobalXPubs.Add( - bitcoinExtPubKey, - rootedKeyPath - ); + if (!result.GlobalXPubs.ContainsKey(bitcoinExtPubKey)) + { + result.GlobalXPubs.Add( + bitcoinExtPubKey, + rootedKeyPath + ); + } + foreach (var selectedUtxo in selectedUtxOs) { @@ -79,13 +83,24 @@ public static void RemoveDuplicateUTXOs(this UTXOChanges utxoChanges) input?.GetCoin()?.Outpoint == selectedUtxo.Outpoint); var coin = coins.FirstOrDefault(x => x.Outpoint == selectedUtxo.Outpoint); - if (coin != null && input != null && + if (input == null) + { + var errorMessage = $"Couldn't get coin for: {selectedUtxo.Outpoint}"; + logger?.LogError(errorMessage); + throw new ArgumentException(errorMessage, nameof(derivedPubKey)); + } + + if (coin != null && ( wallet.IsHotWallet && (coin as Coin).ScriptPubKey == derivedPubKey.WitHash.ScriptPubKey || !wallet.IsHotWallet && (coin as ScriptCoin).Redeem.GetAllPubKeys().Contains(derivedPubKey)) ) { - input.AddKeyPath(derivedPubKey, addressRootedKeyPath); + if (!input.HDKeyPaths.ContainsKey(derivedPubKey)) + { + input.AddKeyPath(derivedPubKey, addressRootedKeyPath); + + } } else { @@ -227,7 +242,7 @@ public static async Task GetFeeRateResult(Network nbXplorerNet { //TODO Maybe the block confirmation count can be a parameter. feeRateResult = - await nbxplorerClient.GetFeeRateAsync(6, default); + await nbxplorerClient.GetFeeRateAsync(1, default); } return feeRateResult; diff --git a/src/Helpers/ValidationHelper.cs b/src/Helpers/ValidationHelper.cs index 56a8a6ac..8315462f 100644 --- a/src/Helpers/ValidationHelper.cs +++ b/src/Helpers/ValidationHelper.cs @@ -17,16 +17,17 @@ * */ -using System.Globalization; using System.Text.RegularExpressions; using Blazorise; using FundsManager.Data.Models; -using FundsManager.Helpers; +using NBitcoin; namespace FundsManager.Helpers; public static class ValidationHelper { + private static Network network = CurrentNetworkHelper.GetCurrentNetwork(); + /// /// Validates that the name of the item introduced in the form is not null and is not only whitespaces. /// @@ -41,7 +42,7 @@ public static void ValidateName(ValidatorEventArgs obj) obj.Status = ValidationStatus.Error; } } - + public static void ValidateUsername(ValidatorEventArgs obj, List users, string currentUserId) { obj.Status = ValidationStatus.Success; @@ -57,15 +58,20 @@ public static void ValidateUsername(ValidatorEventArgs obj, List Constants.MAXIMUM_CHANNEL_CAPACITY_SATS_REGTEST && network == Network.RegTest) { - obj.ErrorText = "The amount must be greater than 20.000"; + obj.ErrorText = "The amount selected must be lower than 16.777.215"; obj.Status = ValidationStatus.Error; } } diff --git a/src/Migrations/20230529155647_ChangelessChannelOperation.Designer.cs b/src/Migrations/20230529155647_ChangelessChannelOperation.Designer.cs new file mode 100644 index 00000000..6a580e76 --- /dev/null +++ b/src/Migrations/20230529155647_ChangelessChannelOperation.Designer.cs @@ -0,0 +1,1171 @@ +// +using System; +using FundsManager.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FundsManager.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20230529155647_ChangelessChannelOperation")] + partial class ChangelessChannelOperation + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationUserNode", b => + { + b.Property("NodesId") + .HasColumnType("integer"); + + b.Property("UsersId") + .HasColumnType("text"); + + b.HasKey("NodesId", "UsersId"); + + b.HasIndex("UsersId"); + + b.ToTable("ApplicationUserNode"); + }); + + modelBuilder.Entity("ChannelOperationRequestFMUTXO", b => + { + b.Property("ChannelOperationRequestsId") + .HasColumnType("integer"); + + b.Property("UtxosId") + .HasColumnType("integer"); + + b.HasKey("ChannelOperationRequestsId", "UtxosId"); + + b.HasIndex("UtxosId"); + + b.ToTable("ChannelOperationRequestFMUTXO"); + }); + + modelBuilder.Entity("FMUTXOWalletWithdrawalRequest", b => + { + b.Property("UTXOsId") + .HasColumnType("integer"); + + b.Property("WalletWithdrawalRequestsId") + .HasColumnType("integer"); + + b.HasKey("UTXOsId", "WalletWithdrawalRequestsId"); + + b.HasIndex("WalletWithdrawalRequestsId"); + + b.ToTable("FMUTXOWalletWithdrawalRequest"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BtcCloseAddress") + .HasColumnType("text"); + + b.Property("ChanId") + .HasColumnType("numeric(20,0)"); + + b.Property("CreatedByNodeGuard") + .HasColumnType("boolean"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DestinationNodeId") + .HasColumnType("integer"); + + b.Property("FundingTx") + .IsRequired() + .HasColumnType("text"); + + b.Property("FundingTxOutputIndex") + .HasColumnType("bigint"); + + b.Property("IsAutomatedLiquidityEnabled") + .HasColumnType("boolean"); + + b.Property("IsPrivate") + .HasColumnType("boolean"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceNodeId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DestinationNodeId"); + + b.HasIndex("SourceNodeId"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.ChannelOperationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountCryptoUnit") + .HasColumnType("integer"); + + b.Property("Changeless") + .HasColumnType("boolean"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("ClosingReason") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DestNodeId") + .HasColumnType("integer"); + + b.Property("IsChannelPrivate") + .HasColumnType("boolean"); + + b.Property("JobId") + .HasColumnType("text"); + + b.Property("RequestType") + .HasColumnType("integer"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceNodeId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("WalletId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("DestNodeId"); + + b.HasIndex("SourceNodeId"); + + b.HasIndex("UserId"); + + b.HasIndex("WalletId"); + + b.ToTable("ChannelOperationRequests"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.ChannelOperationRequestPSBT", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelOperationRequestId") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsFinalisedPSBT") + .HasColumnType("boolean"); + + b.Property("IsInternalWalletPSBT") + .HasColumnType("boolean"); + + b.Property("IsTemplatePSBT") + .HasColumnType("boolean"); + + b.Property("PSBT") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserSignerId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ChannelOperationRequestId"); + + b.HasIndex("UserSignerId"); + + b.ToTable("ChannelOperationRequestPSBTs"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.FMUTXO", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("OutputIndex") + .HasColumnType("bigint"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("TxId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("FMUTXOs"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.InternalWallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DerivationPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("MasterFingerprint") + .HasColumnType("text"); + + b.Property("MnemonicString") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("XPUB") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("InternalWallets"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.Key", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("InternalWalletId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsBIP39ImportedKey") + .HasColumnType("boolean"); + + b.Property("IsCompromised") + .HasColumnType("boolean"); + + b.Property("MasterFingerprint") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Path") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("XPUB") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InternalWalletId"); + + b.HasIndex("UserId"); + + b.ToTable("Keys"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.LiquidityRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("MinimumLocalBalance") + .HasColumnType("numeric"); + + b.Property("MinimumRemoteBalance") + .HasColumnType("numeric"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("RebalanceTarget") + .HasColumnType("numeric"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId") + .IsUnique(); + + b.HasIndex("NodeId"); + + b.HasIndex("WalletId"); + + b.ToTable("LiquidityRules"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AutosweepEnabled") + .HasColumnType("boolean"); + + b.Property("ChannelAdminMacaroon") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("JobId") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReturningFundsWalletId") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("PubKey") + .IsUnique(); + + b.HasIndex("ReturningFundsWalletId"); + + b.ToTable("Nodes"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.Wallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BIP39Seedphrase") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("InternalWalletId") + .HasColumnType("integer"); + + b.Property("InternalWalletMasterFingerprint") + .HasColumnType("text"); + + b.Property("InternalWalletSubDerivationPath") + .HasColumnType("text"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsBIP39Imported") + .HasColumnType("boolean"); + + b.Property("IsCompromised") + .HasColumnType("boolean"); + + b.Property("IsFinalised") + .HasColumnType("boolean"); + + b.Property("IsHotWallet") + .HasColumnType("boolean"); + + b.Property("MofN") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletAddressType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("InternalWalletId"); + + b.HasIndex("InternalWalletSubDerivationPath", "InternalWalletMasterFingerprint") + .IsUnique(); + + b.ToTable("Wallets"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.WalletWithdrawalRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DestinationAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("JobId") + .HasColumnType("text"); + + b.Property("RejectCancelDescription") + .HasColumnType("text"); + + b.Property("RequestMetadata") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.Property("WalletId") + .HasColumnType("integer"); + + b.Property("WithdrawAllFunds") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("UserRequestorId"); + + b.HasIndex("WalletId"); + + b.ToTable("WalletWithdrawalRequests"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.WalletWithdrawalRequestPSBT", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsFinalisedPSBT") + .HasColumnType("boolean"); + + b.Property("IsInternalWalletPSBT") + .HasColumnType("boolean"); + + b.Property("IsTemplatePSBT") + .HasColumnType("boolean"); + + b.Property("PSBT") + .IsRequired() + .HasColumnType("text"); + + b.Property("SignerId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SignerId"); + + b.HasIndex("WalletWithdrawalRequestId"); + + b.ToTable("WalletWithdrawalRequestPSBTs"); + }); + + modelBuilder.Entity("KeyWallet", b => + { + b.Property("KeysId") + .HasColumnType("integer"); + + b.Property("WalletsId") + .HasColumnType("integer"); + + b.HasKey("KeysId", "WalletsId"); + + b.HasIndex("WalletsId"); + + b.ToTable("KeyWallet"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Discriminator") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + + b.HasDiscriminator("Discriminator").HasValue("IdentityUser"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("FundsManager.Data.Models.ApplicationUser", b => + { + b.HasBaseType("Microsoft.AspNetCore.Identity.IdentityUser"); + + b.HasDiscriminator().HasValue("ApplicationUser"); + }); + + modelBuilder.Entity("ApplicationUserNode", b => + { + b.HasOne("FundsManager.Data.Models.Node", null) + .WithMany() + .HasForeignKey("NodesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FundsManager.Data.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChannelOperationRequestFMUTXO", b => + { + b.HasOne("FundsManager.Data.Models.ChannelOperationRequest", null) + .WithMany() + .HasForeignKey("ChannelOperationRequestsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FundsManager.Data.Models.FMUTXO", null) + .WithMany() + .HasForeignKey("UtxosId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FMUTXOWalletWithdrawalRequest", b => + { + b.HasOne("FundsManager.Data.Models.FMUTXO", null) + .WithMany() + .HasForeignKey("UTXOsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FundsManager.Data.Models.WalletWithdrawalRequest", null) + .WithMany() + .HasForeignKey("WalletWithdrawalRequestsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FundsManager.Data.Models.Channel", b => + { + b.HasOne("FundsManager.Data.Models.Node", "DestinationNode") + .WithMany() + .HasForeignKey("DestinationNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FundsManager.Data.Models.Node", "SourceNode") + .WithMany() + .HasForeignKey("SourceNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DestinationNode"); + + b.Navigation("SourceNode"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.ChannelOperationRequest", b => + { + b.HasOne("FundsManager.Data.Models.Channel", "Channel") + .WithMany("ChannelOperationRequests") + .HasForeignKey("ChannelId"); + + b.HasOne("FundsManager.Data.Models.Node", "DestNode") + .WithMany("ChannelOperationRequestsAsDestination") + .HasForeignKey("DestNodeId"); + + b.HasOne("FundsManager.Data.Models.Node", "SourceNode") + .WithMany("ChannelOperationRequestsAsSource") + .HasForeignKey("SourceNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FundsManager.Data.Models.ApplicationUser", "User") + .WithMany("ChannelOperationRequests") + .HasForeignKey("UserId"); + + b.HasOne("FundsManager.Data.Models.Wallet", "Wallet") + .WithMany("ChannelOperationRequestsAsSource") + .HasForeignKey("WalletId"); + + b.Navigation("Channel"); + + b.Navigation("DestNode"); + + b.Navigation("SourceNode"); + + b.Navigation("User"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.ChannelOperationRequestPSBT", b => + { + b.HasOne("FundsManager.Data.Models.ChannelOperationRequest", "ChannelOperationRequest") + .WithMany("ChannelOperationRequestPsbts") + .HasForeignKey("ChannelOperationRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FundsManager.Data.Models.ApplicationUser", "UserSigner") + .WithMany() + .HasForeignKey("UserSignerId"); + + b.Navigation("ChannelOperationRequest"); + + b.Navigation("UserSigner"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.Key", b => + { + b.HasOne("FundsManager.Data.Models.InternalWallet", "InternalWallet") + .WithMany() + .HasForeignKey("InternalWalletId"); + + b.HasOne("FundsManager.Data.Models.ApplicationUser", "User") + .WithMany("Keys") + .HasForeignKey("UserId"); + + b.Navigation("InternalWallet"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.LiquidityRule", b => + { + b.HasOne("FundsManager.Data.Models.Channel", "Channel") + .WithMany("LiquidityRules") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FundsManager.Data.Models.Node", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FundsManager.Data.Models.Wallet", "Wallet") + .WithMany("LiquidityRules") + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + + b.Navigation("Node"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.Node", b => + { + b.HasOne("FundsManager.Data.Models.Wallet", "ReturningFundsWallet") + .WithMany() + .HasForeignKey("ReturningFundsWalletId"); + + b.Navigation("ReturningFundsWallet"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.Wallet", b => + { + b.HasOne("FundsManager.Data.Models.InternalWallet", "InternalWallet") + .WithMany() + .HasForeignKey("InternalWalletId"); + + b.Navigation("InternalWallet"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.WalletWithdrawalRequest", b => + { + b.HasOne("FundsManager.Data.Models.ApplicationUser", "UserRequestor") + .WithMany("WalletWithdrawalRequests") + .HasForeignKey("UserRequestorId"); + + b.HasOne("FundsManager.Data.Models.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserRequestor"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.WalletWithdrawalRequestPSBT", b => + { + b.HasOne("FundsManager.Data.Models.ApplicationUser", "Signer") + .WithMany() + .HasForeignKey("SignerId"); + + b.HasOne("FundsManager.Data.Models.WalletWithdrawalRequest", "WalletWithdrawalRequest") + .WithMany("WalletWithdrawalRequestPSBTs") + .HasForeignKey("WalletWithdrawalRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Signer"); + + b.Navigation("WalletWithdrawalRequest"); + }); + + modelBuilder.Entity("KeyWallet", b => + { + b.HasOne("FundsManager.Data.Models.Key", null) + .WithMany() + .HasForeignKey("KeysId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FundsManager.Data.Models.Wallet", null) + .WithMany() + .HasForeignKey("WalletsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FundsManager.Data.Models.Channel", b => + { + b.Navigation("ChannelOperationRequests"); + + b.Navigation("LiquidityRules"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.ChannelOperationRequest", b => + { + b.Navigation("ChannelOperationRequestPsbts"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.Node", b => + { + b.Navigation("ChannelOperationRequestsAsDestination"); + + b.Navigation("ChannelOperationRequestsAsSource"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.Wallet", b => + { + b.Navigation("ChannelOperationRequestsAsSource"); + + b.Navigation("LiquidityRules"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.WalletWithdrawalRequest", b => + { + b.Navigation("WalletWithdrawalRequestPSBTs"); + }); + + modelBuilder.Entity("FundsManager.Data.Models.ApplicationUser", b => + { + b.Navigation("ChannelOperationRequests"); + + b.Navigation("Keys"); + + b.Navigation("WalletWithdrawalRequests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Migrations/20230529155647_ChangelessChannelOperation.cs b/src/Migrations/20230529155647_ChangelessChannelOperation.cs new file mode 100644 index 00000000..c81c9adf --- /dev/null +++ b/src/Migrations/20230529155647_ChangelessChannelOperation.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FundsManager.Migrations +{ + public partial class ChangelessChannelOperation : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Changeless", + table: "ChannelOperationRequests", + type: "boolean", + nullable: false, + defaultValue: false); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Changeless", + table: "ChannelOperationRequests"); + } + } +} diff --git a/src/Pages/ChannelRequests.razor b/src/Pages/ChannelRequests.razor index bca1812b..77f7845c 100644 --- a/src/Pages/ChannelRequests.razor +++ b/src/Pages/ChannelRequests.razor @@ -6,6 +6,7 @@ @using NBitcoin @using FundsManager.Jobs @using Google.Protobuf +@using NBXplorer.Models @attribute [Authorize(Roles = "FinanceManager, Superadmin, NodeManager")] Channel Operation Requests @@ -13,21 +14,21 @@ @if (_isFinanceManager || _isNodeManager) { - @if (_isFinanceManager) - { -

Requests awaiting my signature

- } - else if (_isNodeManager) - { -

Requests awaiting signature by a Finance Manager

- } + @if (_isFinanceManager) + { +

Requests awaiting my signature

+ } + else if (_isNodeManager) + { +

Requests awaiting signature by a Finance Manager

+ } - @if (_isNodeManager) // Admin can be node manager and finance manager - { - - } + @if (_isNodeManager) // Admin can be node manager and finance manager + { + + }
-
+
- - + + @@ -141,8 +142,43 @@ } +
+ or use + +
+ @if (_selectedWalletId.HasValue && SelectedUTXOs.Count > 0) + { + Selected @(SelectedUTXOs.Count) UTXOs, this is a changeless operation + } + else + { + Default coin selection strategy selected + }
+ @if (Constants.FEE_SELECTION_ENABLED) + { + + +
+ + + + + + +
+
+
+ } @@ -198,6 +234,8 @@ + +
@@ -211,10 +249,10 @@ } -
-
+
+

All Requests

-
+
- + @context.CreationDatetime.Humanize() @@ -303,21 +341,19 @@ + SignedPSBT="@_psbt"/> + OnSubmit="@RejectOrCancelRequest"/> + OnSubmit="@ApproveOperationSubmitConfirmationModal"/> + + @inject IChannelOperationRequestRepository ChannelOperationRequestRepository @inject IChannelOperationRequestPSBTRepository ChannelOperationRequestPsbtRepository @inject IToastService ToastService @inject ILightningService LightningService -@inject ISchedulerFactory SchedulerFactory +@inject ISchedulerFactory SchedulerFactory @inject IWalletRepository WalletRepository @inject INodeRepository NodeRepository -@inject NavigationManager uriHelper; +@inject ICoinSelectionService CoinSelectionService @code { private List? _channelRequests; @@ -361,6 +400,10 @@ private bool _isNodeManager = false; private ConfirmationModal _approveOperationConfirmationModal; private ConfirmationModal _markRequestAsFailedConfirmationModal; + private UTXOSelectorModal _utxoSelectorModalRef; + private List SelectedUTXOs = new(); + private MempoolRecommendedFeesTypes FeesSelection; + private long FeeAmount; // New Request integration private List _allWallets = new List(); @@ -369,10 +412,10 @@ private List? _manageableNodes; private int _selectedSourceNodeId; private Node? _selectedDestNode; - private int _selectedWalletId; + private int? _selectedWalletId; private string? _destNodeName; private long _amount = Constants.MINIMUM_CHANNEL_CAPACITY_SATS; - private bool _selectedPrivate = false; + private bool _selectedPrivate = false; //Validation private Validation? _walletValidation; @@ -390,7 +433,7 @@ private ApplicationUser? LoggedUser { get; set; } [CascadingParameter] - private ClaimsPrincipal? ClaimsPrincipal {get; set; } + private ClaimsPrincipal? ClaimsPrincipal { get; set; } protected override async Task OnInitializedAsync() { @@ -408,6 +451,14 @@ await LoadData(); } + private async Task OnShowNewChannelRequestModal() + { + _utxoSelectorModalRef.ClearModal(); + FeesSelection = 0; + FeeAmount = 0; + await datagridRef.New(); + } + private async Task ResetChannelRequestRejectModal() { await datagridRef.Edit(null); @@ -458,13 +509,11 @@ private void OnSelectedSourceNode(int nodeId) { _selectedSourceNodeId = nodeId; - } private void OnSelectedWallet(int walletId) { - - _selectedWalletId = walletId; + _selectedWalletId = walletId == 0 ? null : walletId; } private async Task SearchNode() @@ -475,17 +524,15 @@ var foundNode = await LightningService.GetNodeInfo(_destNodeName); if (foundNode != null) { - _selectedDestNode = (await NodeRepository.GetByPubkey(_destNodeName)); - //if not found we create it.. + //if not found we create it.. if (_selectedDestNode == null) { _selectedDestNode = new Node { Name = foundNode.Alias, PubKey = _destNodeName, - }; var nodeAddResult = await NodeRepository.AddAsync(_selectedDestNode); @@ -496,7 +543,7 @@ } } - // Refresh the list of available source nodes and take out the one selected + // Refresh the list of available source nodes and take out the one selected _manageableNodes = await NodeRepository.GetAllManagedByUser(LoggedUser.Id); _manageableNodes = _manageableNodes.Where(node => node.Id != _selectedDestNode?.Id).ToList(); _destNodeValidation.Clear(); @@ -508,15 +555,20 @@ { if (LoggedUser == null) return; - Validation?[] validators = {_destNodeValidation, _sourceNodeValidation, _walletValidation, _capacityValidation}; + List validators = new() { _destNodeValidation, _sourceNodeValidation, _walletValidation }; + if (SelectedUTXOs.Count == 0) + { + validators.Add(_capacityValidation); + } if (validators.All(v => v != null && (int)v.Validate() == 1)) { if (_selectedDestNode?.Id != _selectedSourceNodeId) { + var amount = SelectedUTXOs.Count > 0 ? SelectedUTXOsValue() : _amount; var request = new ChannelOperationRequest() { - SatsAmount = _amount, + SatsAmount = amount, RequestType = OperationRequestType.Open, Description = "Created by user via Funds Manager", WalletId = _selectedWalletId, @@ -524,10 +576,13 @@ UserId = LoggedUser.Id, SourceNodeId = _selectedSourceNodeId, DestNodeId = _selectedDestNode?.Id, - IsChannelPrivate = _selectedPrivate + IsChannelPrivate = _selectedPrivate, + Changeless = SelectedUTXOs.Count > 0 }; - var selectedWallet = await WalletRepository.GetById(_selectedWalletId); + var selectedWallet = await WalletRepository.GetById(_selectedWalletId.Value); + + if (selectedWallet.IsHotWallet) { _selectedRequest = request; @@ -542,11 +597,17 @@ if (createChannelResult.Item1) { ToastService.ShowSuccess("Open channel request created!"); + + if (SelectedUTXOs.Count > 0) + { + await CoinSelectionService.LockUTXOs(SelectedUTXOs, request, BitcoinRequestType.ChannelOperation); + } } else { ToastService.ShowError(createChannelResult.Item2); } + _utxoSelectorModalRef.ClearModal(); } else { @@ -560,7 +621,6 @@ #endregion - private static bool RequestPendingFilter(ChannelOperationRequest req) { return req.Status.Equals(ChannelOperationRequestStatus.Pending) || req.Status == ChannelOperationRequestStatus.PSBTSignaturesPending; @@ -575,10 +635,14 @@ { _selectedRequest = req; _selectedStatus = status; - switch(_selectedStatus) + switch (_selectedStatus) { - case ChannelOperationRequestStatus.Rejected: _selectedStatusActionString = "Reject"; break; - case ChannelOperationRequestStatus.Cancelled: _selectedStatusActionString = "Cancel"; break; + case ChannelOperationRequestStatus.Rejected: + _selectedStatusActionString = "Reject"; + break; + case ChannelOperationRequestStatus.Cancelled: + _selectedStatusActionString = "Cancel"; + break; } await _rejectCancelModalRef.ShowModal(); } @@ -611,28 +675,25 @@ { _selectedRequest = channelOperationRequest; _psbt = string.Empty; - if (_selectedRequest != null && !_selectedRequest.AreAllRequiredHumanSignaturesCollected) { + if (_selectedRequest != null && !_selectedRequest.AreAllRequiredHumanSignaturesCollected) + { var (templatePsbt,noUtxosAvailable) = (await LightningService.GenerateTemplatePSBT(_selectedRequest)); if (templatePsbt != null) { _templatePSBTString = templatePsbt.ToBase64(); await _psbtSignRef.ShowModal(); - } else { if (noUtxosAvailable) { ToastService.ShowError("No UTXOs found for this wallet, please wait for other requests to be confirmed or fund the wallet with more UTXOs"); - } else { ToastService.ShowError("Something went wrong"); - } } - } } @@ -644,13 +705,13 @@ { ToastService.ShowError("Error: Not all fields were set"); } - else { + else + { ChannelOperationRequestPSBT channelOperationRequestPsbt = new() { ChannelOperationRequestId = _selectedRequest.Id, PSBT = _psbtSignRef.SignedPSBT, UserSignerId = LoggedUser.Id, - }; var addResult = await ChannelOperationRequestPsbtRepository.AddAsync(channelOperationRequestPsbt); @@ -668,28 +729,17 @@ else { ToastService.ShowError("Invalid PSBT"); - } } else { ToastService.ShowError("Error while saving the signature"); - } await FetchRequests(); await _psbtSignRef.HideModal(); StateHasChanged(); - } - - - } - - private bool IsStatusCancellable(ChannelOperationRequestStatus status) - { - return status is ChannelOperationRequestStatus.Pending - or ChannelOperationRequestStatus.Approved; } private void RejectReasonValidator(ValidatorEventArgs e) @@ -721,10 +771,16 @@ else { ToastService.ShowError(createChannelResult.Item2); + _utxoSelectorModalRef.ClearModal(); await _approveOperationConfirmationModal.CloseModal(); return; } + if (SelectedUTXOs.Count > 0) + { + await CoinSelectionService.LockUTXOs(SelectedUTXOs, _selectedRequest, BitcoinRequestType.ChannelOperation); + } + var (templatePsbt, noUtxosAvailable) = (await LightningService.GenerateTemplatePSBT(_selectedRequest)); if (templatePsbt == null) { @@ -738,6 +794,7 @@ } _selectedRequest.Status = ChannelOperationRequestStatus.Failed; ChannelOperationRequestRepository.Update(_selectedRequest); + _utxoSelectorModalRef.ClearModal(); await _approveOperationConfirmationModal.CloseModal(); await FetchRequests(); StateHasChanged(); @@ -756,9 +813,10 @@ { ToastService.ShowError("Invalid PSBT"); } - + await FetchRequests(); + _utxoSelectorModalRef.ClearModal(); await _approveOperationConfirmationModal.CloseModal(); } } @@ -767,7 +825,7 @@ { try { - //TODO Async notifications when the channel has opened -> event / notifications system + //TODO Async notifications when the channel has opened -> event / notifications system var scheduler = await SchedulerFactory.GetScheduler(); var map = new JobDataMap(); @@ -777,7 +835,7 @@ var job = RetriableJob.Create(map, _selectedRequest.Id.ToString(), retryList); await scheduler.ScheduleJob(job.Job, job.Trigger); - // TODO: Check job id + // TODO: Check job id _selectedRequest.JobId = job.Job.Key.ToString(); var jobUpdateResult = ChannelOperationRequestRepository.Update(_selectedRequest); @@ -818,7 +876,7 @@ } catch (Exception? e) { - ToastService.ShowError("Error while marking request as failed"); + ToastService.ShowError("Error while marking request as failed"); } finally { @@ -832,4 +890,31 @@ _selectedRequestForMarkingAsFailed = context; await _markRequestAsFailedConfirmationModal.ShowModal(); } + + private async Task OpenCoinSelectionModal() + { + _utxoSelectorModalRef.ShowModal(_selectedWalletId.Value); + } + + private void OnCloseCoinSelectionModal(List selectedUTXOs) + { + SelectedUTXOs = selectedUTXOs; + if (SelectedUTXOs.Count > 0) + { + _amount = SelectedUTXOsValue(); + } + StateHasChanged(); + } + + private long SelectedUTXOsValue() + { + return SelectedUTXOs.Sum(x => ((Money)x.Value).Satoshi); + } + + private void ClearSelectedUTXOs() + { + _utxoSelectorModalRef.ClearModal(); + SelectedUTXOs.Clear(); + StateHasChanged(); + } } \ No newline at end of file diff --git a/src/Pages/Channels.razor b/src/Pages/Channels.razor index b97dbd1d..424c73ba 100644 --- a/src/Pages/Channels.razor +++ b/src/Pages/Channels.razor @@ -199,7 +199,7 @@ - + @@ -498,11 +498,11 @@ await ClearManagementModal(); _selectedChannel = channel; - + var destinationNode = await NodeRepository.GetById(channel.DestinationNodeId); var sourceNode = await NodeRepository.GetById(channel.SourceNodeId); var node = String.IsNullOrEmpty(sourceNode.ChannelAdminMacaroon) ? destinationNode : sourceNode; - + //If there is a liquidity rule for this channel, we load it, the first one _currentLiquidityRule = _selectedChannel?.LiquidityRules.FirstOrDefault() ?? new LiquidityRule { @@ -567,7 +567,7 @@ arg1.Status = ValidationStatus.Success; //If the minimum local balance is 0 this cannot be 0 - if ((_currentLiquidityRule.MinimumLocalBalance == 0 || _currentLiquidityRule.MinimumLocalBalance == null) + if ((_currentLiquidityRule.MinimumLocalBalance == 0 || _currentLiquidityRule.MinimumLocalBalance == null) && (_currentLiquidityRule.MinimumRemoteBalance == 0 || _currentLiquidityRule.MinimumRemoteBalance == null)) { arg1.Status = ValidationStatus.Error; @@ -685,7 +685,7 @@ await FetchData(); } - + private bool checkDisableCloseChannelButton(Channel channel) { ChannelOperationRequest? lastRequest = channel.ChannelOperationRequests.LastOrDefault(); diff --git a/src/Pages/Nodes.razor b/src/Pages/Nodes.razor index 0d97bf9e..39e122f7 100644 --- a/src/Pages/Nodes.razor +++ b/src/Pages/Nodes.razor @@ -102,7 +102,7 @@ - + @context.CreationDatetime.Humanize() @@ -261,7 +261,7 @@ cts.Cancel(); // Cancel Task.Delay cts.Dispose(); } - + private async Task ShowDeleteDialog(Node node) { if (await MessageService.Confirm($"Are you sure you want to delete { node.Name }?", "Confirmation")) @@ -279,14 +279,14 @@ _nodes = await NodeRepository.GetAll(); } - + } } await GetData(); } - + private async Task CreateJobs(Node node) { try @@ -305,10 +305,10 @@ map = new JobDataMap(); map.Put("managedNodeId", node.Id.ToString()); - + var acceptorJob = SimpleJob.Create(map, node.Id.ToString()); await scheduler.ScheduleJob(acceptorJob.Job, acceptorJob.Trigger); - + ToastService.ShowSuccess("Node subscription job created"); } catch diff --git a/src/Pages/Wallets.razor b/src/Pages/Wallets.razor index 86a96cd4..d0272672 100644 --- a/src/Pages/Wallets.razor +++ b/src/Pages/Wallets.razor @@ -121,7 +121,7 @@ - + @context.CreationDatetime.Humanize() diff --git a/src/Pages/Withdrawals.razor b/src/Pages/Withdrawals.razor index b42d738f..e1bc7326 100644 --- a/src/Pages/Withdrawals.razor +++ b/src/Pages/Withdrawals.razor @@ -204,7 +204,7 @@ @context?.Status.Humanize() - + @context.CreationDatetime.Humanize() @@ -319,7 +319,7 @@ @context?.Status.Humanize() - + @context.CreationDatetime.Humanize() diff --git a/src/Services/CoinSelectionService.cs b/src/Services/CoinSelectionService.cs index 75dc7c19..79ac209f 100644 --- a/src/Services/CoinSelectionService.cs +++ b/src/Services/CoinSelectionService.cs @@ -41,7 +41,14 @@ public interface ICoinSelectionService /// /// /// - public Task LockUTXOs(List selectedUTXOs, IBitcoinRequest bitcoinRequest, IBitcoinRequestRepository bitcoinRequestRepository); + public Task LockUTXOs(List selectedUTXOs, IBitcoinRequest bitcoinRequest, BitcoinRequestType requestType); + + /// + /// Gets the locked UTXOs from a request + /// + /// + /// + public Task> GetLockedUTXOsForRequest(IBitcoinRequest bitcoinRequest, BitcoinRequestType requestType); public Task<(List coins, List selectedUTXOs)> GetTxInputCoins( List availableUTXOs, @@ -55,33 +62,65 @@ public class CoinSelectionService: ICoinSelectionService private readonly IMapper _mapper; private readonly IFMUTXORepository _fmutxoRepository; private readonly INBXplorerService _nbXplorerService; + private readonly IChannelOperationRequestRepository _channelOperationRequestRepository; + private readonly IWalletWithdrawalRequestRepository _walletWithdrawalRequestRepository; public CoinSelectionService( ILogger logger, IMapper mapper, IFMUTXORepository fmutxoRepository, - INBXplorerService nbXplorerService + INBXplorerService nbXplorerService, + IChannelOperationRequestRepository channelOperationRequestRepository, + IWalletWithdrawalRequestRepository walletWithdrawalRequestRepository ) { _logger = logger; _mapper = mapper; _fmutxoRepository = fmutxoRepository; _nbXplorerService = nbXplorerService; + _channelOperationRequestRepository = channelOperationRequestRepository; + _walletWithdrawalRequestRepository = walletWithdrawalRequestRepository; + } + + private IBitcoinRequestRepository GetRepository(BitcoinRequestType requestType) + { + return requestType switch + { + BitcoinRequestType.ChannelOperation => _channelOperationRequestRepository, + BitcoinRequestType.WalletWithdrawal => _walletWithdrawalRequestRepository, + _ => throw new NotImplementedException() + }; } - public async Task LockUTXOs(List selectedUTXOs, IBitcoinRequest bitcoinRequest, IBitcoinRequestRepository bitcoinRequestRepository) + public async Task LockUTXOs(List selectedUTXOs, IBitcoinRequest bitcoinRequest, BitcoinRequestType requestType) { // We "lock" the PSBT to the channel operation request by adding to its UTXOs collection for later checking var utxos = selectedUTXOs.Select(x => _mapper.Map(x)).ToList(); - var addUTXOSOperation = await bitcoinRequestRepository.AddUTXOs(bitcoinRequest, utxos); - if (!addUTXOSOperation.Item1) + var addUTXOsOperation = await GetRepository(requestType).AddUTXOs(bitcoinRequest, utxos); + if (!addUTXOsOperation.Item1) { _logger.LogError( $"Could not add the following utxos({utxos.Humanize()}) to op request:{bitcoinRequest.Id}"); } } + public async Task> GetLockedUTXOsForRequest(IBitcoinRequest bitcoinRequest, BitcoinRequestType requestType) + { + var getUTXOsOperation = await GetRepository(requestType).GetUTXOs(bitcoinRequest); + if (!getUTXOsOperation.Item1) + { + _logger.LogError( + $"Could not get utxos from {requestType.ToString()} request:{bitcoinRequest.Id}"); + return new(); + } + + // TODO: Convert from fmutxo to utxo by calling nbxplorer api with the list of txids + var lockedUTXOsList = getUTXOsOperation.Item2.Select(utxo => utxo.TxId); + var utxos = await _nbXplorerService.GetUTXOsAsync(bitcoinRequest.Wallet.GetDerivationStrategy()); + return utxos.Confirmed.UTXOs.Where(utxo => lockedUTXOsList.Contains(utxo.Outpoint.Hash.ToString())).ToList(); + } + public async Task> GetAvailableUTXOsAsync(DerivationStrategyBase derivationStrategy) { var lockedUTXOs = await _fmutxoRepository.GetLockedUTXOs(); @@ -118,9 +157,6 @@ public async Task> GetAvailableUTXOsAsync(DerivationStrategyBase deri IBitcoinRequest request, DerivationStrategyBase derivationStrategy) { - var utxoChanges = await _nbXplorerService.GetUTXOsAsync(derivationStrategy, default); - utxoChanges.RemoveDuplicateUTXOs(); - var satsAmount = request.SatsAmount; var selectedUTXOs = await LightningHelper.SelectUTXOsByOldest(request.Wallet, satsAmount, availableUTXOs, _logger); diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs index 4b12cfe4..ff697a21 100644 --- a/src/Services/LightningService.cs +++ b/src/Services/LightningService.cs @@ -17,6 +17,7 @@ * */ +using System.Runtime.InteropServices; using FundsManager.Data.Models; using FundsManager.Data.Repositories.Interfaces; using Google.Protobuf; @@ -27,7 +28,6 @@ using NBXplorer.DerivationStrategy; using NBXplorer.Models; using System.Security.Cryptography; -using AutoMapper; using FundsManager.Data; using FundsManager.Helpers; using Microsoft.EntityFrameworkCore; @@ -116,9 +116,6 @@ public class LightningService : ILightningService private readonly IChannelOperationRequestRepository _channelOperationRequestRepository; private readonly INodeRepository _nodeRepository; private readonly IDbContextFactory _dbContextFactory; - private readonly IMapper _mapper; - private readonly IWalletRepository _walletRepository; - private readonly IFMUTXORepository _ifmutxoRepository; private readonly IChannelOperationRequestPSBTRepository _channelOperationRequestPsbtRepository; private readonly IChannelRepository _channelRepository; private readonly IRemoteSignerService _remoteSignerService; @@ -129,9 +126,6 @@ public LightningService(ILogger logger, IChannelOperationRequestRepository channelOperationRequestRepository, INodeRepository nodeRepository, IDbContextFactory dbContextFactory, - IMapper mapper, - IWalletRepository walletRepository, - IFMUTXORepository ifmutxoRepository, IChannelOperationRequestPSBTRepository channelOperationRequestPsbtRepository, IChannelRepository channelRepository, IRemoteSignerService remoteSignerService, @@ -144,9 +138,6 @@ ICoinSelectionService coinSelectionService _channelOperationRequestRepository = channelOperationRequestRepository; _nodeRepository = nodeRepository; _dbContextFactory = dbContextFactory; - _mapper = mapper; - _walletRepository = walletRepository; - _ifmutxoRepository = ifmutxoRepository; _channelOperationRequestPsbtRepository = channelOperationRequestPsbtRepository; _channelRepository = channelRepository; _remoteSignerService = remoteSignerService; @@ -194,12 +185,19 @@ public async Task OpenChannel(ChannelOperationRequest channelOperationRequest) var combinedPSBT = GetCombinedPsbt(channelOperationRequest, _logger); + //32 bytes of secure randomness for the pending channel id (lnd) var pendingChannelId = RandomNumberGenerator.GetBytes(32); var pendingChannelIdHex = Convert.ToHexString(pendingChannelId); try { + var virtualSize=combinedPSBT.GetGlobalTransaction().GetVirtualSize()+22; //22 bytes for the 1 segwit output + var feeRateResult = await LightningHelper.GetFeeRateResult(network, _nbXplorerService); + + var totalFees = new Money(virtualSize * feeRateResult.FeeRate.SatoshiPerByte, MoneyUnit.Satoshi); + + long fundingAmount = channelOperationRequest.Changeless ? channelOperationRequest.SatsAmount - totalFees : channelOperationRequest.SatsAmount; //We prepare the request (shim) with the base PSBT we had presigned with the UTXOs to fund the channel var openChannelRequest = new OpenChannelRequest { @@ -212,7 +210,7 @@ public async Task OpenChannel(ChannelOperationRequest channelOperationRequest) PendingChanId = ByteString.CopyFrom(pendingChannelId) } }, - LocalFundingAmount = channelOperationRequest.SatsAmount, + LocalFundingAmount = fundingAmount, CloseAddress = closeAddress.Address.ToString(), Private = channelOperationRequest.IsChannelPrivate, NodePubkey = ByteString.CopyFrom(Convert.FromHexString(destination.PubKey)), @@ -397,29 +395,32 @@ public async Task OpenChannel(ChannelOperationRequest channelOperationRequest) }; var channelfundingTx = fundedPSBT.GetGlobalTransaction(); + var totalOut = new Money(channelOperationRequest.SatsAmount, MoneyUnit.Satoshi); - //We manually fix the change (it was wrong from the Base template due to nbitcoin requiring a change on a PSBT) - - var totalIn = new Money(0L); - - foreach (var input in fundedPSBT.Inputs) + if (!channelOperationRequest.Changeless) { - totalIn += (input.GetTxOut()?.Value); + if (fundedPSBT.TryGetVirtualSize(out var vsize)) + { + var totalIn = fundedPSBT.Inputs.Sum(i => i.GetTxOut()?.Value); + //We manually fix the change (it was wrong from the Base template due to nbitcoin requiring a change on a PSBT) + var totalChangefulFees = new Money(vsize * feeRateResult.FeeRate.SatoshiPerByte, MoneyUnit.Satoshi); + var changeOutput = channelfundingTx.Outputs.SingleOrDefault(o => o.Value != channelOperationRequest.SatsAmount) ?? channelfundingTx.Outputs.First(); + changeOutput.Value = totalIn - totalOut - totalChangefulFees; + + //We merge changeFixedPSBT with the other PSBT with the change fixed + fundedPSBT = channelfundingTx.CreatePSBT(network).UpdateFrom(fundedPSBT); + } + else + { + throw new ExternalException("VSized could not be calculated for the funded PSBT, channel operation request id: {RequestId}", channelOperationRequest.Id); + } } - var totalOut = new Money(channelOperationRequest.SatsAmount, MoneyUnit.Satoshi); - var totalFees = combinedPSBT.GetFee(); - channelfundingTx.Outputs[0].Value = totalIn - totalOut - totalFees; - - //We merge changeFixedPSBT with the other PSBT with the change fixed - - var changeFixedPSBT = channelfundingTx.CreatePSBT(network).UpdateFrom(fundedPSBT); - PSBT? finalSignedPSBT = null; //We check the way the nodeguard signs, with the nodeguard remote signer or with the embedded signer if (Constants.ENABLE_REMOTE_SIGNER) { - finalSignedPSBT = await _remoteSignerService.Sign(changeFixedPSBT); + finalSignedPSBT = await _remoteSignerService.Sign(fundedPSBT); if (finalSignedPSBT == null) { const string errorMessage = "The signed PSBT was null, something went wrong while signing with the remote signer"; @@ -435,7 +436,7 @@ public async Task OpenChannel(ChannelOperationRequest channelOperationRequest) derivationStrategyBase, channelfundingTx, network, - changeFixedPSBT, + fundedPSBT, _logger); if (finalSignedPSBT == null) @@ -529,11 +530,13 @@ public async Task OpenChannel(ChannelOperationRequest channelOperationRequest) } else { + _logger.LogError("TX Check failed for channel operation request id: {RequestId} reason: {Reason}", channelOperationRequest.Id, checkTx); CancelPendingChannel(source, pendingChannelId, client); } } else { + _logger.LogError("Could not parse the PSBT for funding channel operation request id: {RequestId}", channelOperationRequest.Id); CancelPendingChannel(source, pendingChannelId, client); } @@ -552,7 +555,8 @@ public async Task OpenChannel(ChannelOperationRequest channelOperationRequest) CancelPendingChannel(source, pendingChannelId, client); - //TODO Mark as failed (?) + //TODO: We have to separate the exceptions between the ones that are retriable and the ones that are not + //TODO: and mark the channel operation request as failed automatically when they are not retriable throw; } } @@ -872,7 +876,8 @@ public void CancelPendingChannel(Node source, byte[] pendingChannelId, IUnmockab } } - var availableUTXOs = await _coinSelectionService.GetAvailableUTXOsAsync(derivationStrategy); + var previouslyLockedUTXOs = await _coinSelectionService.GetLockedUTXOsForRequest(channelOperationRequest, BitcoinRequestType.ChannelOperation); + var availableUTXOs = previouslyLockedUTXOs.Count > 0 ? previouslyLockedUTXOs : await _coinSelectionService.GetAvailableUTXOsAsync(derivationStrategy); var (multisigCoins, selectedUtxOs) = await _coinSelectionService.GetTxInputCoins(availableUTXOs, channelOperationRequest, derivationStrategy); if (multisigCoins == null || !multisigCoins.Any()) @@ -910,15 +915,26 @@ public void CancelPendingChannel(Node source, byte[] pendingChannelId, IUnmockab .SetChange(changeAddress.Address) .SendEstimatedFees(feeRateResult.FeeRate); - result.Item1 = builder.BuildPSBT(false); + var originalPSBT = builder.BuildPSBT(false); + + //Hack to remove outputs + var combinedPsbTtx = originalPSBT.GetGlobalTransaction(); + if (channelOperationRequest.Changeless) + { + combinedPsbTtx.Outputs.Clear(); + } + + result.Item1 = combinedPsbTtx.CreatePSBT(network); + //Hack to make sure that witness and non-witness UTXOs are added to the PSBT //Hack, see https://github.com/MetacoSA/NBitcoin/issues/1112 for details foreach (var input in result.Item1.Inputs) { + input.WitnessUtxo = originalPSBT.Inputs.FirstOrDefault(x=> x.PrevOut == input.PrevOut)?.WitnessUtxo; + input.NonWitnessUtxo = originalPSBT.Inputs.FirstOrDefault(x=> x.PrevOut == input.PrevOut)?.NonWitnessUtxo; input.SighashType = SigHash.None; } - //Additional fields to support PSBT signing with a HW or the Remote Signer var psbt = LightningHelper.AddDerivationData(channelOperationRequest.Wallet, result.Item1, selectedUtxOs, multisigCoins, _logger); result = (psbt, result.Item2); } @@ -927,7 +943,10 @@ public void CancelPendingChannel(Node source, byte[] pendingChannelId, IUnmockab _logger.LogError(e, "Error while generating base PSBT"); } - await _coinSelectionService.LockUTXOs(selectedUtxOs, channelOperationRequest, _channelOperationRequestRepository); + if (previouslyLockedUTXOs.Count == 0) + { + await _coinSelectionService.LockUTXOs(selectedUtxOs, channelOperationRequest, BitcoinRequestType.ChannelOperation); + } // The template PSBT is saved for later reuse if (result.Item1 != null) diff --git a/src/Services/NBXplorerService.cs b/src/Services/NBXplorerService.cs index ce65fe03..11e40332 100644 --- a/src/Services/NBXplorerService.cs +++ b/src/Services/NBXplorerService.cs @@ -40,8 +40,17 @@ public Task GetScanUTXOSetInformationAsync(DerivationStrate CancellationToken cancellation = default(CancellationToken)); } +public enum MempoolRecommendedFeesTypes +{ + EconomyFee, + FastestFee, + HourFee, + HalfHourFee, + CustomFee +} + /// -/// Response from +/// Response from /// public class MempoolRecommendedFees { @@ -132,15 +141,15 @@ public async Task GetFeeRateAsync(int blockCount, FeeRate fall try { - + var recommendedFees = await _httpClient.GetFromJsonAsync($"{mempoolEndpoint}/api/v1/fees/recommended"); if (recommendedFees != null) { var feerate = new GetFeeRateResult { - FeeRate = new FeeRate((decimal) recommendedFees.HourFee), - BlockCount = 6 // 60 mins / 10 mins + FeeRate = new FeeRate((decimal) recommendedFees.FastestFee), + BlockCount = 1 // 60 mins / 10 mins }; return feerate; diff --git a/src/Shared/ConfirmationModal.razor b/src/Shared/ConfirmationModal.razor index 5679d139..89088c8e 100644 --- a/src/Shared/ConfirmationModal.razor +++ b/src/Shared/ConfirmationModal.razor @@ -22,14 +22,13 @@ [Parameter, EditorRequired] public string Body { get; set; } = ""; - + [Parameter] public string CancelLabel { get; set; } = "Cancel"; [Parameter] public string OkLabel { get; set; } = "Ok"; private Modal? _modalRef { get; set; } - private Validation? _reasonValidation; [Parameter, EditorRequired] public EventCallback OnCancel { get; set; } diff --git a/src/Shared/MainLayout.razor.css b/src/Shared/MainLayout.razor.css index 21804c8a..438b30bd 100644 --- a/src/Shared/MainLayout.razor.css +++ b/src/Shared/MainLayout.razor.css @@ -52,7 +52,6 @@ main { } .sidebar { - width: 250px; height: 100vh; position: sticky; top: 0; diff --git a/src/Shared/NavMenu.razor.css b/src/Shared/NavMenu.razor.css index f7da34c4..c8719f44 100644 --- a/src/Shared/NavMenu.razor.css +++ b/src/Shared/NavMenu.razor.css @@ -21,6 +21,7 @@ .nav-item { font-size: 1rem; padding-bottom: 0.5rem; + white-space: nowrap; } .nav-item:first-of-type { diff --git a/src/Shared/UTXOSelectorModal.razor b/src/Shared/UTXOSelectorModal.razor new file mode 100644 index 00000000..e3bb476d --- /dev/null +++ b/src/Shared/UTXOSelectorModal.razor @@ -0,0 +1,115 @@ +@using NBXplorer.Models +@using NBXplorer.DerivationStrategy +@using NBitcoin +@inject ICoinSelectionService _CoinSelectionService + + + + + Select UTXOs for this wallet + + + + + + + + + @String.Join(",", _validation.Messages ?? GetUTXOsValues()) + + + + + + + + + + +@inject ICoinSelectionService CoinSelectionService +@inject IWalletRepository WalletRepository + +@code { + private Network network = CurrentNetworkHelper.GetCurrentNetwork(); + private Modal? _modalRef { get; set; } + public List UTXOList { get; set; } = new(); + + private List SelectedUTXOs { get; set; } = new(); + private List PrevSelectedUTXOs { get; set; } = new(); + + [Parameter, EditorRequired] + public Action> OnClose { get; set; } = _ => { }; + + private Validation _validation { get; set; } = new(); + + public void ClearModal() + { + UTXOList.Clear(); + SelectedUTXOs.Clear(); + } + + private async Task CancelSelection() + { + SelectedUTXOs = PrevSelectedUTXOs; + await _modalRef.Close(CloseReason.UserClosing); + } + + private async Task CloseModal() + { + if (await _validation.ValidateAsync() != ValidationStatus.Error) + { + OnClose(SelectedUTXOs); + await _modalRef.Close(CloseReason.UserClosing); + } + } + + public async Task ShowModal(int walletId) + { + PrevSelectedUTXOs = SelectedUTXOs; + var selectedWallet = await WalletRepository.GetById(walletId); + var utxosList = await CoinSelectionService.GetAvailableUTXOsAsync(selectedWallet.GetDerivationStrategy()); + UTXOList = utxosList; + + // Refresh the list with the new objects, or selection wont be preserved + var hashes = SelectedUTXOs.Select(utxos => utxos.Outpoint.Hash); + SelectedUTXOs = utxosList.Where(utxo => hashes.Contains(utxo.Outpoint.Hash)).ToList(); + + await _modalRef.Show(); + StateHasChanged(); + } + + private string[] GetUTXOsValues() + { + var value = SelectedUTXOs.Sum(x => ((Money)x.Value).Satoshi); + return new[] + { + $"Selected {SelectedUTXOs.Count} for an amount of {value}" + }; + } + + private void RejectReasonValidatorForUTXOsSelector(ValidatorEventArgs e) + { + var selectedUTXOsValue = SelectedUTXOs.Sum(x => ((Money)x.Value).Satoshi); + if (selectedUTXOsValue == 0) return; + if (selectedUTXOsValue < Constants.MINIMUM_CHANNEL_CAPACITY_SATS) + { + e.ErrorText = "The combined amount of the UTXOs selected must be greater than 20.000"; + e.Status = ValidationStatus.Error; + } + else if (selectedUTXOsValue > Constants.MAXIMUM_CHANNEL_CAPACITY_SATS_REGTEST && network == Network.RegTest) + { + e.ErrorText = "The combined amount of the UTXOs selected must be lower than 16.777.215"; + e.Status = ValidationStatus.Error; + } + else + { + e.Status = ValidationStatus.Success; + } + } +} \ No newline at end of file diff --git a/test/FundsManager.Tests/Services/BitcoinServiceTests.cs b/test/FundsManager.Tests/Services/BitcoinServiceTests.cs index 0eccd104..662fffd0 100644 --- a/test/FundsManager.Tests/Services/BitcoinServiceTests.cs +++ b/test/FundsManager.Tests/Services/BitcoinServiceTests.cs @@ -183,7 +183,7 @@ async Task GenerateTemplatePSBT_LegacyMultiSigSucceeds() fmutxoRepository .Setup(x => x.GetLockedUTXOs(null, null)) .ReturnsAsync(new List()); - var coinSelectionService = new CoinSelectionService(_logger, mapper.Object, fmutxoRepository.Object, nbXplorerService.Object); + var coinSelectionService = new CoinSelectionService(_logger, mapper.Object, fmutxoRepository.Object, nbXplorerService.Object, null, null); var bitcoinService = new BitcoinService(_logger, mapper.Object, walletWithdrawalRequestRepository.Object, walletWithdrawalRequestPsbtRepository.Object, null, null, nbXplorerService.Object, coinSelectionService); @@ -252,7 +252,7 @@ async Task GenerateTemplatePSBT_MultiSigSucceeds() .Setup(x => x.GetLockedUTXOs(null , null)) .ReturnsAsync(new List()); - var coinSelectionService = new CoinSelectionService(_logger, mapper.Object, fmutxoRepository.Object, nbXplorerService.Object); + var coinSelectionService = new CoinSelectionService(_logger, mapper.Object, fmutxoRepository.Object, nbXplorerService.Object, null, null); var bitcoinService = new BitcoinService(_logger, mapper.Object, walletWithdrawalRequestRepository.Object, walletWithdrawalRequestPsbtRepository.Object, null, null, nbXplorerService.Object, coinSelectionService); @@ -320,7 +320,7 @@ async Task GenerateTemplatePSBT_SingleSigSucceeds() .Setup(x => x.GetLockedUTXOs(null, null)) .ReturnsAsync(new List()); - var coinSelectionService = new CoinSelectionService(_logger, mapper.Object, fmutxoRepository.Object, nbXplorerService.Object); + var coinSelectionService = new CoinSelectionService(_logger, mapper.Object, fmutxoRepository.Object, nbXplorerService.Object, null, null); var bitcoinService = new BitcoinService(_logger, mapper.Object, walletWithdrawalRequestRepository.Object, walletWithdrawalRequestPsbtRepository.Object, null, null, nbXplorerService.Object, coinSelectionService); diff --git a/test/FundsManager.Tests/Services/LightningServiceTests.cs b/test/FundsManager.Tests/Services/LightningServiceTests.cs index d785261e..8aff26ee 100644 --- a/test/FundsManager.Tests/Services/LightningServiceTests.cs +++ b/test/FundsManager.Tests/Services/LightningServiceTests.cs @@ -83,7 +83,7 @@ public async Task OpenChannel_ChannelOperationRequestNotFound() .ReturnsAsync(null as ChannelOperationRequest); var lightningService = new LightningService(_logger, channelOperationRequestRepository.Object, null, - dbContextFactory.Object, null, null, null, null, null, null, new Mock().Object, null); + dbContextFactory.Object, null, null, null, new Mock().Object, null); var operationRequest = new ChannelOperationRequest { @@ -588,9 +588,6 @@ public async Task OpenChannel_SuccessLegacyMultiSig() channelOperationRequestRepository.Object, nodeRepository.Object, dbContextFactory.Object, - null, - null, - null, channelOperationRequestPsbtRepository.Object, channelRepository.Object, null, @@ -819,9 +816,6 @@ public async Task OpenChannel_SuccessMultiSig() channelOperationRequestRepository.Object, nodeRepository.Object, dbContextFactory.Object, - null, - null, - null, channelOperationRequestPsbtRepository.Object, channelRepository.Object, null, @@ -1050,9 +1044,6 @@ public async Task OpenChannel_SuccessSingleSigBip39() channelOperationRequestRepository.Object, nodeRepository.Object, dbContextFactory.Object, - null, - null, - null, channelOperationRequestPsbtRepository.Object, channelRepository.Object, null, @@ -1280,9 +1271,6 @@ public async Task OpenChannel_SuccessSingleSig() channelOperationRequestRepository.Object, nodeRepository.Object, dbContextFactory.Object, - null, - null, - null, channelOperationRequestPsbtRepository.Object, channelRepository.Object, null, @@ -1372,9 +1360,6 @@ public async Task CloseChannel_Succeeds() null, null, null, - null, - null, - null, channelRepository.Object, null, null,