diff --git a/src/Data/Models/WalletWithdrawalRequest.cs b/src/Data/Models/WalletWithdrawalRequest.cs index 4d8bdd9c..b3d25332 100644 --- a/src/Data/Models/WalletWithdrawalRequest.cs +++ b/src/Data/Models/WalletWithdrawalRequest.cs @@ -147,6 +147,11 @@ public class WalletWithdrawalRequest : Entity, IEquatable public decimal? CustomFeeRate { get; set; } + /// + /// Coin selection strategy requested for this withdrawal. Null means the requestor did not pick one. + /// + public CoinSelectionStrategy? CoinSelectionStrategy { 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/Migrations/20260918121451_AddWithdrawalCoinSelectionStrategy.Designer.cs b/src/Migrations/20260918121451_AddWithdrawalCoinSelectionStrategy.Designer.cs new file mode 100644 index 00000000..6cbf48fa --- /dev/null +++ b/src/Migrations/20260918121451_AddWithdrawalCoinSelectionStrategy.Designer.cs @@ -0,0 +1,1944 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodeGuard.Data; +using NodeGuard.Helpers; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace NodeGuard.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260918121451_AddWithdrawalCoinSelectionStrategy")] + partial class AddWithdrawalCoinSelectionStrategy + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .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("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() + .HasMaxLength(21) + .HasColumnType("character varying(21)"); + + 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().HasValue("IdentityUser"); + + b.UseTphMappingStrategy(); + }); + + 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("NodeGuard.Data.Models.APIToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .HasColumnType("integer"); + + b.Property("Details") + .HasColumnType("text"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("ObjectAffected") + .HasColumnType("integer"); + + b.Property("ObjectId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Username") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("NodeGuard.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("ClosedAt") + .HasColumnType("timestamp with time zone"); + + 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("IsAutoRebalanceEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsAutomatedLiquidityEnabled") + .HasColumnType("boolean"); + + b.Property("IsDynamicFeeEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + 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("NodeGuard.Data.Models.ChannelFeeState", 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("LastAppliedInboundBaseMsat") + .HasColumnType("integer"); + + b.Property("LastAppliedInboundPpm") + .HasColumnType("integer"); + + b.Property("LastAppliedOutboundBaseFeeMsat") + .HasColumnType("bigint"); + + b.Property("LastAppliedOutboundPpm") + .HasColumnType("bigint"); + + b.Property("LastComputedTarget") + .HasColumnType("double precision"); + + b.Property("LastFeeUpdateAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastObservedRatio") + .HasColumnType("double precision"); + + b.Property("ManagedNodePubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ManagedNodePubKey") + .IsUnique(); + + b.ToTable("ChannelFeeStates"); + }); + + modelBuilder.Entity("NodeGuard.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("FeeRate") + .HasColumnType("numeric"); + + b.Property("InitialChannelBaseFeeMsat") + .HasColumnType("bigint"); + + b.Property("InitialChannelFeeRatePpm") + .HasColumnType("bigint"); + + b.Property("IsChannelPrivate") + .HasColumnType("boolean"); + + b.Property("MempoolRecommendedFeesType") + .HasColumnType("integer"); + + b.Property("RequestType") + .HasColumnType("integer"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceNodeId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property>("StatusLogs") + .HasColumnType("jsonb"); + + 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("NodeGuard.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.HasIndex(new[] { "ChannelOperationRequestId" }, "IX_ChannelOperationRequestPSBTs_Template") + .IsUnique() + .HasFilter("\"IsTemplatePSBT\""); + + b.ToTable("ChannelOperationRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelRoutingState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgeBlocks") + .HasColumnType("bigint"); + + b.Property("ChanIdLnd") + .HasColumnType("numeric(20,0)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("ConsecutiveCategoryCyclesInNewState") + .HasColumnType("bigint"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("EmaLocalRatio") + .HasColumnType("double precision"); + + b.Property("FundingBlockHeight") + .HasColumnType("bigint"); + + b.Property("LastCategorizedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastEvaluatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKnownLifetime") + .HasColumnType("bigint"); + + b.Property("LastKnownNumUpdates") + .HasColumnType("bigint"); + + b.Property("LastKnownUptime") + .HasColumnType("bigint"); + + b.Property("ManagedNodePubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("NetFlowRatio") + .HasColumnType("double precision"); + + b.Property("PeerFlowCategory") + .HasColumnType("integer"); + + b.Property("PeerInitiated") + .HasColumnType("boolean"); + + b.Property("PendingCategory") + .HasColumnType("integer"); + + b.Property("PullMsatWindow") + .HasColumnType("bigint"); + + b.Property("PushMsatWindow") + .HasColumnType("bigint"); + + b.Property("TargetLocalRatio") + .HasColumnType("double precision"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ManagedNodePubKey") + .IsUnique(); + + b.ToTable("ChannelRoutingStates"); + }); + + modelBuilder.Entity("NodeGuard.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("NodeGuard.Data.Models.ForwardingHtlcEvent", b => + { + b.Property("ManagedNodePubKey") + .HasColumnType("text"); + + b.Property("IncomingChannelId") + .HasColumnType("numeric(20,0)"); + + b.Property("OutgoingChannelId") + .HasColumnType("numeric(20,0)"); + + b.Property("IncomingHtlcId") + .HasColumnType("numeric(20,0)"); + + b.Property("OutgoingHtlcId") + .HasColumnType("numeric(20,0)"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("EventCase") + .HasColumnType("integer"); + + b.Property("EventTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("FailureDetail") + .HasColumnType("integer"); + + b.Property("FailureString") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FeeMsat") + .HasColumnType("bigint"); + + b.Property("GrossFeeMsat") + .HasColumnType("bigint"); + + b.Property("InboundFeeMsat") + .HasColumnType("bigint"); + + b.Property("InboundFeePpm") + .HasColumnType("bigint"); + + b.Property("IncomingAmountMsat") + .HasColumnType("numeric(20,0)"); + + b.Property("IncomingPeerAlias") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IncomingTimelock") + .HasColumnType("bigint"); + + b.Property("ManagedNodeName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Outcome") + .HasColumnType("integer"); + + b.Property("OutgoingAmountMsat") + .HasColumnType("numeric(20,0)"); + + b.Property("OutgoingPeerAlias") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OutgoingTimelock") + .HasColumnType("bigint"); + + b.Property("RoutingFeePpm") + .HasColumnType("bigint"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WireFailureCode") + .HasColumnType("integer"); + + b.HasKey("ManagedNodePubKey", "IncomingChannelId", "OutgoingChannelId", "IncomingHtlcId", "OutgoingHtlcId"); + + b.HasIndex("CreationDatetime"); + + b.HasIndex("EventTimestamp"); + + b.ToTable("ForwardingHtlcEvents"); + }); + + modelBuilder.Entity("NodeGuard.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("NodeGuard.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("NodeGuard.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("IsReverseSwapWalletRule") + .HasColumnType("boolean"); + + b.Property("MinimumLocalBalance") + .HasColumnType("numeric"); + + b.Property("MinimumRemoteBalance") + .HasColumnType("numeric"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("RebalanceTarget") + .HasColumnType("numeric"); + + b.Property("ReverseSwapAddress") + .HasColumnType("text"); + + b.Property("ReverseSwapWalletId") + .HasColumnType("integer"); + + b.Property("SwapWalletId") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId") + .IsUnique(); + + b.HasIndex("NodeId"); + + b.HasIndex("ReverseSwapWalletId"); + + b.HasIndex("SwapWalletId"); + + b.ToTable("LiquidityRules"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowPositiveInboundFees") + .HasColumnType("boolean"); + + b.Property("AutoLiquidityManagementEnabled") + .HasColumnType("boolean"); + + b.Property("AutoRebalanceEnabled") + .HasColumnType("boolean"); + + 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("DynamicFeeManagementEnabled") + .HasColumnType("boolean"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("FortySwapEndpoint") + .HasColumnType("text"); + + b.Property("FortySwapWeight") + .HasColumnType("integer"); + + b.Property("FundsDestinationWalletId") + .HasColumnType("integer"); + + b.Property("IsNodeDisabled") + .HasColumnType("boolean"); + + b.Property("LoopSwapWeight") + .HasColumnType("integer"); + + b.Property("LoopdCert") + .HasColumnType("text"); + + b.Property("LoopdEndpoint") + .HasColumnType("text"); + + b.Property("LoopdMacaroon") + .HasColumnType("text"); + + b.Property("MaxRebalanceCostToEarnRatio") + .HasColumnType("double precision"); + + b.Property("MaxRebalancesInFlight") + .HasColumnType("integer"); + + b.Property("MaxSwapRoutingFeeRatio") + .HasColumnType("numeric"); + + b.Property("MaxSwapsInFlight") + .HasColumnType("integer"); + + b.Property("MinimumBalanceThresholdSats") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("RebalanceBudgetRefreshInterval") + .HasColumnType("interval"); + + b.Property("RebalanceBudgetSats") + .HasColumnType("bigint"); + + b.Property("RebalanceBudgetStartDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("RoutingEngineDryRun") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("SwapBudgetRefreshInterval") + .HasColumnType("interval"); + + b.Property("SwapBudgetSats") + .HasColumnType("bigint"); + + b.Property("SwapBudgetStartDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("SwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("SwapMinAmountSats") + .HasColumnType("bigint"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FundsDestinationWalletId"); + + b.HasIndex("PubKey") + .IsUnique(); + + b.ToTable("Nodes"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Rebalance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountBackoffRatio") + .HasColumnType("double precision"); + + b.Property("AttemptNumber") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("FeePaidMsat") + .HasColumnType("bigint"); + + b.Property("FeePaidSats") + .HasColumnType("bigint"); + + b.Property("IsManual") + .HasColumnType("boolean"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("MaxFeePct") + .HasColumnType("double precision"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("PaymentHashHex") + .HasColumnType("text"); + + b.Property("PaymentRequest") + .HasColumnType("text"); + + b.Property("PreimageHex") + .HasColumnType("text"); + + b.Property("RequestedAmountSats") + .HasColumnType("bigint"); + + b.Property("RetryMaxFeePct") + .HasColumnType("double precision"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceChanIdLnd") + .HasColumnType("numeric(20,0)"); + + b.Property("SourceChannelId") + .HasColumnType("integer"); + + b.Property("SourceNodePubKey") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TargetPubkey") + .HasColumnType("text"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("NodeId"); + + b.HasIndex("SourceChannelId"); + + b.HasIndex("UserRequestorId"); + + b.ToTable("Rebalances"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.SwapOut", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DestinationWalletId") + .HasColumnType("integer"); + + b.Property("ErrorDetails") + .HasColumnType("text"); + + b.Property("IsManual") + .HasColumnType("boolean"); + + b.Property("LightningFeeSats") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("OnChainFeeSats") + .HasColumnType("bigint"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("ServiceFeeSats") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DestinationWalletId"); + + b.HasIndex("NodeId"); + + b.HasIndex("UserRequestorId"); + + b.ToTable("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.UTXOTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Outpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Outpoint") + .IsUnique(); + + b.ToTable("UTXOTags"); + }); + + modelBuilder.Entity("NodeGuard.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("ImportedOutputDescriptor") + .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("IsUnSortedMultiSig") + .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("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BumpingWalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.Property("Changeless") + .HasColumnType("boolean"); + + b.Property("CoinSelectionStrategy") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomFeeRate") + .HasColumnType("numeric"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsRbfCancellation") + .HasColumnType("boolean"); + + b.Property("MempoolRecommendedFeesType") + .HasColumnType("integer"); + + b.Property("ReferenceId") + .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("BumpingWalletWithdrawalRequestId"); + + b.HasIndex("UserRequestorId"); + + b.HasIndex("WalletId"); + + b.ToTable("WalletWithdrawalRequests"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestDestination", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("WalletWithdrawalRequestId"); + + b.ToTable("WalletWithdrawalRequestDestinations"); + }); + + modelBuilder.Entity("NodeGuard.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.HasIndex(new[] { "WalletWithdrawalRequestId" }, "IX_WalletWithdrawalRequestPSBTs_Template") + .IsUnique() + .HasFilter("\"IsTemplatePSBT\""); + + b.ToTable("WalletWithdrawalRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ApplicationUser", b => + { + b.HasBaseType("Microsoft.AspNetCore.Identity.IdentityUser"); + + b.HasDiscriminator().HasValue("ApplicationUser"); + }); + + modelBuilder.Entity("ApplicationUserNode", b => + { + b.HasOne("NodeGuard.Data.Models.Node", null) + .WithMany() + .HasForeignKey("NodesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChannelOperationRequestFMUTXO", b => + { + b.HasOne("NodeGuard.Data.Models.ChannelOperationRequest", null) + .WithMany() + .HasForeignKey("ChannelOperationRequestsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.FMUTXO", null) + .WithMany() + .HasForeignKey("UtxosId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FMUTXOWalletWithdrawalRequest", b => + { + b.HasOne("NodeGuard.Data.Models.FMUTXO", null) + .WithMany() + .HasForeignKey("UTXOsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", null) + .WithMany() + .HasForeignKey("WalletWithdrawalRequestsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("KeyWallet", b => + { + b.HasOne("NodeGuard.Data.Models.Key", null) + .WithMany() + .HasForeignKey("KeysId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.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("NodeGuard.Data.Models.APIToken", b => + { + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "Creator") + .WithMany() + .HasForeignKey("CreatorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.HasOne("NodeGuard.Data.Models.Node", "DestinationNode") + .WithMany() + .HasForeignKey("DestinationNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Node", "SourceNode") + .WithMany() + .HasForeignKey("SourceNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DestinationNode"); + + b.Navigation("SourceNode"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelFeeState", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany("ChannelOperationRequests") + .HasForeignKey("ChannelId"); + + b.HasOne("NodeGuard.Data.Models.Node", "DestNode") + .WithMany("ChannelOperationRequestsAsDestination") + .HasForeignKey("DestNodeId"); + + b.HasOne("NodeGuard.Data.Models.Node", "SourceNode") + .WithMany("ChannelOperationRequestsAsSource") + .HasForeignKey("SourceNodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "User") + .WithMany("ChannelOperationRequests") + .HasForeignKey("UserId"); + + b.HasOne("NodeGuard.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("NodeGuard.Data.Models.ChannelOperationRequestPSBT", b => + { + b.HasOne("NodeGuard.Data.Models.ChannelOperationRequest", "ChannelOperationRequest") + .WithMany("ChannelOperationRequestPsbts") + .HasForeignKey("ChannelOperationRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserSigner") + .WithMany() + .HasForeignKey("UserSignerId"); + + b.Navigation("ChannelOperationRequest"); + + b.Navigation("UserSigner"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelRoutingState", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany() + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Key", b => + { + b.HasOne("NodeGuard.Data.Models.InternalWallet", "InternalWallet") + .WithMany() + .HasForeignKey("InternalWalletId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "User") + .WithMany("Keys") + .HasForeignKey("UserId"); + + b.Navigation("InternalWallet"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.LiquidityRule", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany("LiquidityRules") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Wallet", "ReverseSwapWallet") + .WithMany("LiquidityRulesAsReverseSwapWallet") + .HasForeignKey("ReverseSwapWalletId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "SwapWallet") + .WithMany("LiquidityRulesAsSwapWallet") + .HasForeignKey("SwapWalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + + b.Navigation("Node"); + + b.Navigation("ReverseSwapWallet"); + + b.Navigation("SwapWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.HasOne("NodeGuard.Data.Models.Wallet", "FundsDestinationWallet") + .WithMany() + .HasForeignKey("FundsDestinationWalletId"); + + b.Navigation("FundsDestinationWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Rebalance", b => + { + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Channel", "SourceChannel") + .WithMany() + .HasForeignKey("SourceChannelId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany() + .HasForeignKey("UserRequestorId"); + + b.Navigation("Node"); + + b.Navigation("SourceChannel"); + + b.Navigation("UserRequestor"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.SwapOut", b => + { + b.HasOne("NodeGuard.Data.Models.Wallet", "DestinationWallet") + .WithMany("SwapOuts") + .HasForeignKey("DestinationWalletId"); + + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany("SwapOuts") + .HasForeignKey("NodeId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany() + .HasForeignKey("UserRequestorId"); + + b.Navigation("DestinationWallet"); + + b.Navigation("Node"); + + b.Navigation("UserRequestor"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.HasOne("NodeGuard.Data.Models.InternalWallet", "InternalWallet") + .WithMany() + .HasForeignKey("InternalWalletId"); + + b.Navigation("InternalWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "BumpingWalletWithdrawalRequest") + .WithMany() + .HasForeignKey("BumpingWalletWithdrawalRequestId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany("WalletWithdrawalRequests") + .HasForeignKey("UserRequestorId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BumpingWalletWithdrawalRequest"); + + b.Navigation("UserRequestor"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestDestination", b => + { + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "WalletWithdrawalRequest") + .WithMany("WalletWithdrawalRequestDestinations") + .HasForeignKey("WalletWithdrawalRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WalletWithdrawalRequest"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestPSBT", b => + { + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "Signer") + .WithMany() + .HasForeignKey("SignerId"); + + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "WalletWithdrawalRequest") + .WithMany("WalletWithdrawalRequestPSBTs") + .HasForeignKey("WalletWithdrawalRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Signer"); + + b.Navigation("WalletWithdrawalRequest"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.Navigation("ChannelOperationRequests"); + + b.Navigation("LiquidityRules"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.Navigation("ChannelOperationRequestPsbts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.Navigation("ChannelOperationRequestsAsDestination"); + + b.Navigation("ChannelOperationRequestsAsSource"); + + b.Navigation("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.Navigation("ChannelOperationRequestsAsSource"); + + b.Navigation("LiquidityRulesAsReverseSwapWallet"); + + b.Navigation("LiquidityRulesAsSwapWallet"); + + b.Navigation("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.Navigation("WalletWithdrawalRequestDestinations"); + + b.Navigation("WalletWithdrawalRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ApplicationUser", b => + { + b.Navigation("ChannelOperationRequests"); + + b.Navigation("Keys"); + + b.Navigation("WalletWithdrawalRequests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Migrations/20260918121451_AddWithdrawalCoinSelectionStrategy.cs b/src/Migrations/20260918121451_AddWithdrawalCoinSelectionStrategy.cs new file mode 100644 index 00000000..0db06961 --- /dev/null +++ b/src/Migrations/20260918121451_AddWithdrawalCoinSelectionStrategy.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NodeGuard.Migrations +{ + /// + public partial class AddWithdrawalCoinSelectionStrategy : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CoinSelectionStrategy", + table: "WalletWithdrawalRequests", + type: "integer", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CoinSelectionStrategy", + table: "WalletWithdrawalRequests"); + } + } +} diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index 484fbfc3..d50d75e4 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1370,6 +1370,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Changeless") .HasColumnType("boolean"); + b.Property("CoinSelectionStrategy") + .HasColumnType("integer"); + b.Property("CreationDatetime") .HasColumnType("timestamp with time zone"); diff --git a/src/Proto/nodeguard.proto b/src/Proto/nodeguard.proto index b7fb3595..bd615b78 100644 --- a/src/Proto/nodeguard.proto +++ b/src/Proto/nodeguard.proto @@ -200,6 +200,8 @@ message RequestWithdrawalRequest { optional string reference_id = 10; // Destinations for the withdrawal repeated Destination destinations = 11; + // Optional coin selection strategy to be used by the withdrawal. + optional COIN_SELECTION_STRATEGY coin_selection_strategy = 12; } message RequestWithdrawalResponse { diff --git a/src/Rpc/NodeGuardService.cs b/src/Rpc/NodeGuardService.cs index 62130363..24a3085b 100644 --- a/src/Rpc/NodeGuardService.cs +++ b/src/Rpc/NodeGuardService.cs @@ -239,6 +239,23 @@ private void ValidateWithdrawalDestinations(IList destinations, boo } } + /// + /// Maps the proto coin selection strategy onto the domain one. The two enums are kept in sync by hand, so + /// this switches on the members instead of casting the ordinal. + /// + private static CoinSelectionStrategy MapCoinSelectionStrategy(COIN_SELECTION_STRATEGY strategy) + { + return strategy switch + { + COIN_SELECTION_STRATEGY.SmallestFirst => CoinSelectionStrategy.SmallestFirst, + COIN_SELECTION_STRATEGY.BiggestFirst => CoinSelectionStrategy.BiggestFirst, + COIN_SELECTION_STRATEGY.ClosestToTargetFirst => CoinSelectionStrategy.ClosestToTargetFirst, + COIN_SELECTION_STRATEGY.UpToAmount => CoinSelectionStrategy.UpToAmount, + _ => throw new RpcException(new Status(StatusCode.InvalidArgument, + $"Unknown coin selection strategy: {strategy}")) + }; + } + public override async Task RequestWithdrawal(RequestWithdrawalRequest request, ServerCallContext context) { @@ -295,7 +312,10 @@ public override async Task RequestWithdrawal(RequestW Changeless = request.Changeless, MempoolRecommendedFeesType = (MempoolRecommendedFeesType)request.MempoolFeeRate, CustomFeeRate = request.CustomFeeRate, - ReferenceId = request.ReferenceId + ReferenceId = request.ReferenceId, + CoinSelectionStrategy = request.HasCoinSelectionStrategy + ? MapCoinSelectionStrategy(request.CoinSelectionStrategy) + : null }; //Save withdrawal request diff --git a/src/Services/BitcoinService.cs b/src/Services/BitcoinService.cs index acb8bbcd..c0f44677 100644 --- a/src/Services/BitcoinService.cs +++ b/src/Services/BitcoinService.cs @@ -190,12 +190,19 @@ await _coinSelectionService.GetLockedUTXOsForRequest(walletWithdrawalRequest, } - var availableUTXOs = previouslyLockedUTXOs.Count > 0 + var hasLockedUTXOs = previouslyLockedUTXOs.Count > 0; + var availableUTXOs = hasLockedUTXOs ? previouslyLockedUTXOs : await _coinSelectionService.GetAvailableUTXOsAsync(derivationStrategy); + + // The strategy only applies while the selection is still open. Locked UTXOs are the inputs a + // changeless request picked or an RBF replacement has to reuse. A full withdrawal has already had + // its amount set to the whole balance above, from the plain listing, so re-selecting against that + // amount can only come up short. + var selectionIsOpen = !hasLockedUTXOs && !walletWithdrawalRequest.WithdrawAllFunds; var (scriptCoins, selectedUTXOs) = await _coinSelectionService.GetTxInputCoins(availableUTXOs, walletWithdrawalRequest, - derivationStrategy); + derivationStrategy, selectionIsOpen ? walletWithdrawalRequest.CoinSelectionStrategy : null); if (scriptCoins == null || !scriptCoins.Any()) { diff --git a/src/Services/CoinSelectionService.cs b/src/Services/CoinSelectionService.cs index f2188134..8b8efe84 100644 --- a/src/Services/CoinSelectionService.cs +++ b/src/Services/CoinSelectionService.cs @@ -73,10 +73,21 @@ public interface ICoinSelectionService /// public Task> GetFrozenUTXOs(); + /// + /// Picks the inputs to fund a request. + /// + /// The candidate set, used when no strategy is given. + /// + /// + /// + /// When set, NBXplorer orders the UTXOs and is ignored. When null, the + /// oldest-first selection is applied to . + /// public Task<(List coins, List selectedUTXOs)> GetTxInputCoins( List availableUTXOs, IBitcoinRequest request, - DerivationStrategyBase derivationStrategy); + DerivationStrategyBase derivationStrategy, + CoinSelectionStrategy? strategy = null); } public class CoinSelectionService: ICoinSelectionService @@ -283,16 +294,62 @@ public async Task> GetAvailableUTXOsAsync(DerivationStrategyBase deri public async Task<(List coins, List selectedUTXOs)> GetTxInputCoins( List availableUTXOs, IBitcoinRequest request, - DerivationStrategyBase derivationStrategy) + DerivationStrategyBase derivationStrategy, + CoinSelectionStrategy? strategy = null) { var satsAmount = request.SatsAmount; - var selectedUTXOs = await LightningHelper.SelectUTXOsByOldest(request.Wallet, satsAmount, availableUTXOs, _logger); + List selectedUTXOs; + if (strategy.HasValue) + { + // NBXplorer does the ordering. If the custom backend is off or the call fails, + // GetAvailableUTXOsAsync degrades to the plain listing. + var orderedUTXOs = + await GetAvailableUTXOsAsync(derivationStrategy, strategy.Value, 0, satsAmount, satsAmount); + selectedUTXOs = TakeUntilAmountIsCovered(orderedUTXOs, satsAmount, request.Wallet.Id); + } + else + { + selectedUTXOs = + await LightningHelper.SelectUTXOsByOldest(request.Wallet, satsAmount, availableUTXOs, _logger); + } + var coins = await LightningHelper.SelectCoins(request.Wallet, selectedUTXOs); return (coins, selectedUTXOs); } + /// + /// Takes UTXOs in the order they were given until the amount is covered, one UTXO past it so there is + /// room for the fee. Same accumulation as SelectUTXOsByOldest, only without the reordering, so the + /// order the caller asked for survives. An empty list means the set was short, which + /// GenerateTemplatePSBT turns into a NoUTXOsAvailableException. + /// + private List TakeUntilAmountIsCovered(List orderedUTXOs, long satsAmount, int walletId) + { + var selectedUTXOs = new List(); + + var totalSats = orderedUTXOs.Sum(x => ((Money)x.Value).Satoshi); + if (totalSats < satsAmount) + { + _logger.LogError( + "Error, the total UTXOs set balance for walletid: {WalletId} ({AvailableSats} sats) is less than the amount in the request ({RequestedSats} sats)", + walletId, totalSats, satsAmount); + return selectedUTXOs; + } + + var accumulator = 0M; + foreach (var utxo in orderedUTXOs) + { + if (accumulator > satsAmount) break; + + selectedUTXOs.Add(utxo); + accumulator += ((Money)utxo.Value).Satoshi; + } + + return selectedUTXOs; + } + public async Task> GetUTXOsByOutpointAsync(DerivationStrategyBase derivationStrategy, List outPoints) { var utxos = await _nbXplorerService.GetUTXOsAsync(derivationStrategy); diff --git a/src/Services/WithdrawalRequestService.cs b/src/Services/WithdrawalRequestService.cs index 5491f291..686c9673 100644 --- a/src/Services/WithdrawalRequestService.cs +++ b/src/Services/WithdrawalRequestService.cs @@ -246,6 +246,7 @@ private async Task CreateReplacementAsync(int originalR RequestMetadata = original.RequestMetadata, // The replacement represents the same external request, so callers can keep tracking it by reference id. ReferenceId = original.ReferenceId, + CoinSelectionStrategy = original.CoinSelectionStrategy, BumpingWalletWithdrawalRequestId = original.Id, IsRbfCancellation = cancellation, WalletId = original.WalletId, diff --git a/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs b/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs index 65b7079e..d0e46102 100644 --- a/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs +++ b/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs @@ -669,6 +669,89 @@ public async Task RequestWithdrawal_Success() resp.Txid.Should().Be("f103e12f02ac1e5b8826831d4fc8fdb78a707bd00c4e1f191fe5d14458d63d5a"); } + [Theory] + [InlineData(COIN_SELECTION_STRATEGY.SmallestFirst, CoinSelectionStrategy.SmallestFirst)] + [InlineData(COIN_SELECTION_STRATEGY.BiggestFirst, CoinSelectionStrategy.BiggestFirst)] + [InlineData(COIN_SELECTION_STRATEGY.ClosestToTargetFirst, CoinSelectionStrategy.ClosestToTargetFirst)] + [InlineData(COIN_SELECTION_STRATEGY.UpToAmount, CoinSelectionStrategy.UpToAmount)] + public async Task RequestWithdrawal_StoresTheRequestedCoinSelectionStrategy( + COIN_SELECTION_STRATEGY requested, CoinSelectionStrategy expected) + { + //Arrange + var wallet = InitMockRequestWithdrawal(out var walletRepository, + out var nbxplorerService, + out _, + out _, + out _, + out _, + out _, + out var bitcoinService, + out var walletWithdrawalRequestRepository, + out var mockScheduler); + + // Cold wallet, so the request stops at the template PSBT instead of scheduling a PerformWithdrawalJob. + // Every fixture request has id 0, and the job key is the id, so the hot path collides across cases. + wallet.IsHotWallet = false; + + var requestWithdrawalRequest = CreateWithdrawalRequest(wallet.Id, 100); + requestWithdrawalRequest.CoinSelectionStrategy = requested; + + var mockNodeGuardService = CreateNodeGuardService( + walletRepository: walletRepository.Object, + walletWithdrawalRequestRepository: walletWithdrawalRequestRepository.Object, + bitcoinService: bitcoinService.Object, + nbXplorerService: nbxplorerService.Object, + schedulerFactory: mockScheduler); + + //Act + await mockNodeGuardService.RequestWithdrawal(requestWithdrawalRequest, TestServerCallContext.Create()); + + //Assert + walletWithdrawalRequestRepository.Verify( + x => x.AddAsync(It.Is(r => r.CoinSelectionStrategy == expected)), + Times.Once); + } + + /// + /// SmallestFirst is the proto zero value, so a request that omits the field must not be read as a request + /// for SmallestFirst. Null is what tells the selection to keep behaving as it does today. + /// + [Fact] + public async Task RequestWithdrawal_WithoutACoinSelectionStrategy_StoresNull() + { + //Arrange + var wallet = InitMockRequestWithdrawal(out var walletRepository, + out var nbxplorerService, + out _, + out _, + out _, + out _, + out _, + out var bitcoinService, + out var walletWithdrawalRequestRepository, + out var mockScheduler); + + wallet.IsHotWallet = false; + + var requestWithdrawalRequest = CreateWithdrawalRequest(wallet.Id, 100); + + var mockNodeGuardService = CreateNodeGuardService( + walletRepository: walletRepository.Object, + walletWithdrawalRequestRepository: walletWithdrawalRequestRepository.Object, + bitcoinService: bitcoinService.Object, + nbXplorerService: nbxplorerService.Object, + schedulerFactory: mockScheduler); + + //Act + await mockNodeGuardService.RequestWithdrawal(requestWithdrawalRequest, TestServerCallContext.Create()); + + //Assert + requestWithdrawalRequest.HasCoinSelectionStrategy.Should().BeFalse(); + walletWithdrawalRequestRepository.Verify( + x => x.AddAsync(It.Is(r => r.CoinSelectionStrategy == null)), + Times.Once); + } + [Fact] public async Task RequestWithdrawal_NoWallet() { diff --git a/test/NodeGuard.Tests/Services/BitcoinServiceTests.cs b/test/NodeGuard.Tests/Services/BitcoinServiceTests.cs index 719da7c8..7fe7d532 100644 --- a/test/NodeGuard.Tests/Services/BitcoinServiceTests.cs +++ b/test/NodeGuard.Tests/Services/BitcoinServiceTests.cs @@ -1929,4 +1929,112 @@ async Task GenerateTemplatePSBT_WithDuplicateTemplateRows_FailsLoudly() nbXplorerService.Verify(x => x.GetUTXOsAsync(It.IsAny(), default), Times.Never); psbtRepository.Verify(x => x.AddAsync(It.IsAny()), Times.Never); } + + /// + /// Builds a request whose only interesting property is the coin selection strategy, runs + /// GenerateTemplatePSBT far enough to reach the selection, and hands back the strategy it was called with. + /// + private async Task CaptureSelectionStrategy( + CoinSelectionStrategy? requested, List lockedUTXOs, bool withdrawAllFunds = false) + { + var wallet = CreateWallet.SingleSig(_internalWallet); + var withdrawalRequest = new WalletWithdrawalRequest + { + Id = 1, + Status = WalletWithdrawalRequestStatus.Pending, + Wallet = wallet, + CoinSelectionStrategy = requested, + WithdrawAllFunds = withdrawAllFunds, + WalletWithdrawalRequestPSBTs = new List(), + WalletWithdrawalRequestDestinations = new List + { + new() { Address = "bcrt1q590shaxaf5u08ml8jwlzghz99dup3z9592vxal", Amount = 0.01m } + } + }; + + var walletWithdrawalRequestRepository = new Mock(); + walletWithdrawalRequestRepository.Setup(x => x.GetById(It.IsAny())).ReturnsAsync(withdrawalRequest); + + var nbXplorerService = new Mock(); + nbXplorerService.Setup(x => x.GetStatusAsync(default)) + .ReturnsAsync(new StatusResult { IsFullySynched = true }); + + CoinSelectionStrategy? capturedStrategy = null; + var coinSelectionService = new Mock(); + coinSelectionService + .Setup(x => x.GetLockedUTXOsForRequest(It.IsAny(), BitcoinRequestType.WalletWithdrawal)) + .ReturnsAsync(lockedUTXOs); + coinSelectionService + .Setup(x => x.GetAvailableUTXOsAsync(It.IsAny())) + .ReturnsAsync(new List()); + coinSelectionService + .Setup(x => x.GetTxInputCoins(It.IsAny>(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback, IBitcoinRequest, DerivationStrategyBase, CoinSelectionStrategy?>( + (_, _, _, strategy) => capturedStrategy = strategy) + // An empty selection stops GenerateTemplatePSBT right after the call we are watching + .ReturnsAsync((new List(), new List())); + + var bitcoinService = new BitcoinService(_logger, null, walletWithdrawalRequestRepository.Object, + new Mock().Object, null, null, nbXplorerService.Object, + coinSelectionService.Object); + + await bitcoinService.Invoking(x => x.GenerateTemplatePSBT(withdrawalRequest)) + .Should().ThrowAsync(); + + return capturedStrategy; + } + + [Fact] + async Task GenerateTemplatePSBT_PassesTheRequestedCoinSelectionStrategy() + { + var strategy = await CaptureSelectionStrategy(CoinSelectionStrategy.BiggestFirst, new List()); + + strategy.Should().Be(CoinSelectionStrategy.BiggestFirst); + } + + /// + /// Locked UTXOs are the inputs a changeless request picked or an RBF replacement has to reuse. Applying a + /// strategy there would re-select the inputs, so the replacement would no longer replace anything. + /// + [Fact] + async Task GenerateTemplatePSBT_WithLockedUTXOs_IgnoresTheCoinSelectionStrategy() + { + var wallet = CreateWallet.SingleSig(_internalWallet); + var lockedUTXO = new UTXO + { + Outpoint = new OutPoint(new uint256(1), 0), + Value = new Money(100_000L), + KeyPath = KeyPath.Parse("0/0"), + ScriptPubKey = (wallet.GetDerivationStrategy() as StandardDerivationStrategyBase)! + .GetDerivation(KeyPath.Parse("0/0")).ScriptPubKey + }; + + var strategy = await CaptureSelectionStrategy(CoinSelectionStrategy.BiggestFirst, + new List { lockedUTXO }); + + strategy.Should().BeNull(); + } + + /// A request that picked no strategy must reach the selection unchanged. + [Fact] + async Task GenerateTemplatePSBT_WithoutACoinSelectionStrategy_PassesNull() + { + var strategy = await CaptureSelectionStrategy(null, new List()); + + strategy.Should().BeNull(); + } + + /// + /// A full withdrawal has its amount set to the whole available balance before the selection runs, so + /// re-selecting against that amount could only come up short. + /// + [Fact] + async Task GenerateTemplatePSBT_WithdrawAllFunds_IgnoresTheCoinSelectionStrategy() + { + var strategy = await CaptureSelectionStrategy(CoinSelectionStrategy.BiggestFirst, new List(), + withdrawAllFunds: true); + + strategy.Should().BeNull(); + } } diff --git a/test/NodeGuard.Tests/Services/CoinSelectionServiceTests.cs b/test/NodeGuard.Tests/Services/CoinSelectionServiceTests.cs index f72b184f..a5c03790 100644 --- a/test/NodeGuard.Tests/Services/CoinSelectionServiceTests.cs +++ b/test/NodeGuard.Tests/Services/CoinSelectionServiceTests.cs @@ -438,4 +438,171 @@ public async Task GetAvailableUTXOsAsync_ExcludesDustAndLockedUTXOs() availableUTXOs.Should().ContainSingle(); availableUTXOs[0].Outpoint.Should().Be(availableUtxo.Outpoint); } + + /// A UTXO complete enough for LightningHelper.SelectCoins to turn it into a coin. + private static UTXO CreateSpendableUtxo(Wallet wallet, uint index, long satoshis, int confirmations = 1) + { + var keyPath = KeyPath.Parse($"0/{index}"); + + return new UTXO + { + Outpoint = new OutPoint(new uint256(index), 0), + Value = new Money(satoshis), + Confirmations = confirmations, + KeyPath = keyPath, + ScriptPubKey = (wallet.GetDerivationStrategy() as StandardDerivationStrategyBase)! + .GetDerivation(keyPath).ScriptPubKey + }; + } + + private static WalletWithdrawalRequest CreateRequest(Wallet wallet, decimal amountBtc) + { + return new WalletWithdrawalRequest + { + Id = 1, + Wallet = wallet, + WalletWithdrawalRequestDestinations = new List + { + new() { Address = "bcrt1q590shaxaf5u08ml8jwlzghz99dup3z9592vxal", Amount = amountBtc } + } + }; + } + + [Fact] + public async Task GetTxInputCoins_WithStrategy_SelectsInTheOrderNBXplorerReturns() + { + var previousCustomBackend = Constants.NBXPLORER_ENABLE_CUSTOM_BACKEND; + Constants.NBXPLORER_ENABLE_CUSTOM_BACKEND = true; + try + { + // Arrange + var wallet = CreateWallet.SingleSig(_internalWallet); + var derivationStrategy = wallet.GetDerivationStrategy(); + + // SelectUTXOsByOldest pops in ascending confirmations, so it would take the 100k UTXO on its + // own. Following NBXplorer's order instead takes the two small ones. + var small = CreateSpendableUtxo(wallet, 1, 6_000, confirmations: 2); + var smaller = CreateSpendableUtxo(wallet, 2, 5_000, confirmations: 3); + var big = CreateSpendableUtxo(wallet, 3, 100_000, confirmations: 1); + + var fmutxoRepository = new Mock(); + fmutxoRepository.Setup(x => x.GetLockedUTXOs(null, null)).ReturnsAsync(new List()); + + var utxoTagRepository = new Mock(); + utxoTagRepository.Setup(x => x.GetByKeyValue(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + var nbXplorerService = new Mock(); + nbXplorerService + .Setup(x => x.GetUTXOsAsync(It.IsAny(), default)) + .ReturnsAsync(new UTXOChanges + { + Confirmed = new UTXOChange { UTXOs = new List { small, smaller, big } } + }); + nbXplorerService + .Setup(x => x.GetUTXOsByLimitAsync(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), default)) + .ReturnsAsync(new UTXOChanges + { + Confirmed = new UTXOChange { UTXOs = new List { small, smaller, big } } + }); + + var coinSelectionService = new CoinSelectionService(_logger, new Mock().Object, + fmutxoRepository.Object, nbXplorerService.Object, null, null, utxoTagRepository.Object); + + var request = CreateRequest(wallet, 0.0001m); // 10_000 sats + + // Act + var (coins, selectedUTXOs) = await coinSelectionService.GetTxInputCoins( + new List(), request, derivationStrategy, CoinSelectionStrategy.SmallestFirst); + + // Assert + nbXplorerService.Verify(x => x.GetUTXOsByLimitAsync(derivationStrategy, + CoinSelectionStrategy.SmallestFirst, 0, 10_000, 10_000, It.IsAny>(), default), + Times.Once); + selectedUTXOs.Select(x => x.Outpoint).Should() + .Equal(new[] { small.Outpoint, smaller.Outpoint }, "the order NBXplorer returned is kept"); + coins.Should().HaveCount(2); + } + finally + { + Constants.NBXPLORER_ENABLE_CUSTOM_BACKEND = previousCustomBackend; + } + } + + [Fact] + public async Task GetTxInputCoins_WithoutStrategy_KeepsTheOldestFirstSelection() + { + // Arrange + var wallet = CreateWallet.SingleSig(_internalWallet); + var derivationStrategy = wallet.GetDerivationStrategy(); + var utxo = CreateSpendableUtxo(wallet, 1, 100_000); + + var nbXplorerService = new Mock(); + var coinSelectionService = new CoinSelectionService(_logger, new Mock().Object, + new Mock().Object, nbXplorerService.Object, null, null, + new Mock().Object); + + var request = CreateRequest(wallet, 0.0001m); // 10_000 sats + + // Act + var (coins, selectedUTXOs) = await coinSelectionService.GetTxInputCoins( + new List { utxo }, request, derivationStrategy); + + // Assert + selectedUTXOs.Should().ContainSingle().Which.Outpoint.Should().Be(utxo.Outpoint); + coins.Should().ContainSingle(); + nbXplorerService.Verify(x => x.GetUTXOsByLimitAsync(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), default), Times.Never); + } + + [Fact] + public async Task GetTxInputCoins_WithStrategy_ReturnsNothingWhenTheSetIsShort() + { + var previousCustomBackend = Constants.NBXPLORER_ENABLE_CUSTOM_BACKEND; + Constants.NBXPLORER_ENABLE_CUSTOM_BACKEND = true; + try + { + // Arrange + var wallet = CreateWallet.SingleSig(_internalWallet); + var derivationStrategy = wallet.GetDerivationStrategy(); + var utxo = CreateSpendableUtxo(wallet, 1, 1_000); + + var fmutxoRepository = new Mock(); + fmutxoRepository.Setup(x => x.GetLockedUTXOs(null, null)).ReturnsAsync(new List()); + + var utxoTagRepository = new Mock(); + utxoTagRepository.Setup(x => x.GetByKeyValue(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + + var nbXplorerService = new Mock(); + nbXplorerService + .Setup(x => x.GetUTXOsAsync(It.IsAny(), default)) + .ReturnsAsync(new UTXOChanges { Confirmed = new UTXOChange { UTXOs = new List { utxo } } }); + nbXplorerService + .Setup(x => x.GetUTXOsByLimitAsync(It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>(), default)) + .ReturnsAsync(new UTXOChanges { Confirmed = new UTXOChange { UTXOs = new List { utxo } } }); + + var coinSelectionService = new CoinSelectionService(_logger, new Mock().Object, + fmutxoRepository.Object, nbXplorerService.Object, null, null, utxoTagRepository.Object); + + var request = CreateRequest(wallet, 0.0001m); // 10_000 sats, more than the wallet holds + + // Act + var (coins, selectedUTXOs) = await coinSelectionService.GetTxInputCoins( + new List(), request, derivationStrategy, CoinSelectionStrategy.BiggestFirst); + + // Assert: GenerateTemplatePSBT turns an empty selection into NoUTXOsAvailableException + selectedUTXOs.Should().BeEmpty(); + coins.Should().BeEmpty(); + } + finally + { + Constants.NBXPLORER_ENABLE_CUSTOM_BACKEND = previousCustomBackend; + } + } } diff --git a/test/NodeGuard.Tests/Services/WithdrawalRequestServiceTests.cs b/test/NodeGuard.Tests/Services/WithdrawalRequestServiceTests.cs index 612d0743..80448162 100644 --- a/test/NodeGuard.Tests/Services/WithdrawalRequestServiceTests.cs +++ b/test/NodeGuard.Tests/Services/WithdrawalRequestServiceTests.cs @@ -126,6 +126,32 @@ public async Task CreateBumpRequestAsync_HotWallet_CreatesTheBumpInPsbtSignature BitcoinRequestType.WalletWithdrawal), Times.Once); } + /// + /// The replacement stands in for the same original request, so it keeps the requested coin selection strategy + /// the way it keeps RequestMetadata and ReferenceId. Nothing reads the field yet. + /// + [Fact] + public async Task CreateBumpRequestAsync_CarriesTheCoinSelectionStrategyOfTheOriginal() + { + var original = SetupOriginal(HotWallet()); + original.CoinSelectionStrategy = CoinSelectionStrategy.BiggestFirst; + + var bump = await CreateService().CreateBumpRequestAsync(OriginalId, MempoolRecommendedFeesType.CustomFee, 10m); + + bump.CoinSelectionStrategy.Should().Be(CoinSelectionStrategy.BiggestFirst); + } + + [Fact] + public async Task CreateCancelRequestAsync_CarriesTheCoinSelectionStrategyOfTheOriginal() + { + var original = SetupOriginal(HotWallet()); + original.CoinSelectionStrategy = CoinSelectionStrategy.ClosestToTargetFirst; + + var cancel = await CreateService().CreateCancelRequestAsync(OriginalId, MempoolRecommendedFeesType.CustomFee, 10m); + + cancel.CoinSelectionStrategy.Should().Be(CoinSelectionStrategy.ClosestToTargetFirst); + } + [Fact] public async Task CreateBumpRequestAsync_ColdWallet_CreatesTheBumpPendingForApprovers() {