From 86dac3c52762d340c83446c120b64f75845e5c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:58:58 +0200 Subject: [PATCH 01/10] Swap sizes per provider: Loop, 40swap and Spark each have their own min and max A node's single Min/Max swap size becomes one pair per provider: the migration renames it to Loop's and copies it to 40swap's. Spark's default to SPARK_SWAP_MIN_SATS (50000, at least 10000) and SPARK_SWAP_MAX_SATS (2000000). Auto-liquidity now picks the provider first, among those whose min fits the excess balance, then swaps the most that provider takes (for Spark, also within its wallet's room). --- docs/spark-swaps.md | 6 +- src/Data/Models/Node.cs | 28 +- src/Jobs/AutoLiquidityManagementJob.cs | 120 +- ...5603_AddPerProviderSwapAmounts.Designer.cs | 2015 +++++++++++++++++ ...0261014115603_AddPerProviderSwapAmounts.cs | 84 + .../ApplicationDbContextModelSnapshot.cs | 24 +- src/Pages/Nodes.razor | 181 +- src/Services/Spark/SparkSettings.cs | 32 + src/Services/SwapAmountLimits.cs | 39 + .../Jobs/AutoLiquidityManagementJobTests.cs | 73 +- .../Services/Spark/SparkSettingsTests.cs | 10 + 11 files changed, 2503 insertions(+), 109 deletions(-) create mode 100644 src/Migrations/20261014115603_AddPerProviderSwapAmounts.Designer.cs create mode 100644 src/Migrations/20261014115603_AddPerProviderSwapAmounts.cs create mode 100644 src/Services/SwapAmountLimits.cs diff --git a/docs/spark-swaps.md b/docs/spark-swaps.md index 428a2f67..f6961daf 100644 --- a/docs/spark-swaps.md +++ b/docs/spark-swaps.md @@ -86,6 +86,8 @@ While Spark is enabled, invalid settings stop NodeGuard at startup. | `SPARK_SSP_IDENTITY_PUBKEY` | Lightspark's on mainnet; fetched from the SSP on regtest | Required with a custom SSP on mainnet | | `SPARK_MAX_EXIT_FEE_SATS` | `20000` | Highest SSP exit fee accepted per swap | | `SPARK_MAX_BALANCE_SATS` | `10000000` | Most sats a Spark wallet may hold, counting a new swap and its swaps not paid yet, unless it has its own max balance | +| `SPARK_SWAP_MIN_SATS` | `50000` | A node's smallest Spark swap, at least 10000, unless the node sets its own | +| `SPARK_SWAP_MAX_SATS` | `2000000` | A node's largest Spark swap, up to `MAXIMUM_SWAP_OUT_SIZE_BTC`, unless the node sets its own | | `SPARK_EXIT_INTERVAL_MINUTES` | `10`, `1` in a dev environment | How often paid swaps exit on-chain | | `SPARK_EXIT_SPEED` | `SLOW` | `FAST`, `MEDIUM` or `SLOW`: the fee rate swap exits and Withdraw all aim for. `SLOW` is the cheapest and confirms last; the overdue alert waits 6, 12 or 24 hours accordingly | | `SPARK_PAYMENT_TIMEOUT_MINUTES` | `10` | How long LND keeps trying routes for a swap's payment, 1 to 25 (the invoice lasts 30 minutes); a node can set its own in its liquidity settings | @@ -98,7 +100,9 @@ With the remote signer, `REMOTE_SIGNER_ENDPOINT` must be the Function URL with n ## Using it -- **Auto-liquidity.** Give the node a Spark weight and a Spark wallet on the Nodes page; the Loop, 40swap and Spark weights sum to 100. The node's Max swaps in flight limits Spark swaps like any other, and Spark is skipped while the node's Spark wallet is unavailable. A Spark swap is sized down to the room left under the wallet's max balance (counting its swaps not paid yet), and Spark is skipped when that room is below the node's minimum swap. If no other weighted provider is left, the run is skipped without reserving an address. +- **Auto-liquidity.** Give the node a Spark weight and a Spark wallet on the Nodes page; the Loop, 40swap and Spark weights sum to 100. The node's Max swaps in flight limits Spark swaps like any other, and Spark is skipped while the node's Spark wallet is unavailable. + - Each provider has its own min and max swap size on the node (Loop, 40swap and Spark; Spark's default to `SPARK_SWAP_MIN_SATS` / `SPARK_SWAP_MAX_SATS`). Auto-liquidity picks the provider first, among those whose min fits what is above the balance threshold, then swaps the most that provider takes. + - A Spark swap is also sized down to the room left under the wallet's max balance (counting its swaps not paid yet), and Spark is skipped when that room is below the node's Spark min swap. If no other weighted provider is left, the run is skipped without reserving an address. - **New Swap dialog.** Spark lists the nodes whose Spark wallet is ready. There is no up-front quote: the SSP quotes the exit for the leaves the payment brings in. The confirmation shows the caps instead, with their percentage of the swap. - **gRPC.** - `RequestSwapOut` (`provider: SWAP_PROVIDER_SPARK`) returns no destination address: the swap reserves it when it exits, and `GetSwapOut` reports it from then on. diff --git a/src/Data/Models/Node.cs b/src/Data/Models/Node.cs index 9fd8a4ac..e6a93694 100644 --- a/src/Data/Models/Node.cs +++ b/src/Data/Models/Node.cs @@ -80,14 +80,34 @@ public class Node : Entity public bool AutoLiquidityManagementEnabled { get; set; } /// - /// Minimum swap amount in satoshis + /// Minimum Loop swap amount in satoshis /// - public long SwapMinAmountSats { get; set; } + public long LoopSwapMinAmountSats { get; set; } /// - /// Maximum swap amount in satoshis + /// Maximum Loop swap amount in satoshis /// - public long SwapMaxAmountSats { get; set; } + public long LoopSwapMaxAmountSats { get; set; } + + /// + /// Minimum 40swap swap amount in satoshis + /// + public long FortySwapMinAmountSats { get; set; } + + /// + /// Maximum 40swap swap amount in satoshis + /// + public long FortySwapMaxAmountSats { get; set; } + + /// + /// Minimum Spark swap amount in satoshis; null means SPARK_SWAP_MIN_SATS + /// + public long? SparkSwapMinAmountSats { get; set; } + + /// + /// Maximum Spark swap amount in satoshis; null means SPARK_SWAP_MAX_SATS + /// + public long? SparkSwapMaxAmountSats { get; set; } /// /// Maximum number of concurrent swaps to limit HTLC locking diff --git a/src/Jobs/AutoLiquidityManagementJob.cs b/src/Jobs/AutoLiquidityManagementJob.cs index 7775dbad..b3b4d622 100644 --- a/src/Jobs/AutoLiquidityManagementJob.cs +++ b/src/Jobs/AutoLiquidityManagementJob.cs @@ -121,15 +121,16 @@ public async Task Execute(IJobExecutionContext context) } /// - /// Weighted random choice among the providers with a weight that can take a swap now. Spark is left - /// out while the node has no Spark wallet, or its wallet is unavailable or has less room under its max - /// balance than the node's minimum swap (the node's Max swaps in flight already limits its swaps); null when - /// no weighted provider is left. All weights at 0 means Loop, as before. Also returns the Spark wallet's room, - /// to clamp a Spark swap. + /// Weighted random choice among the providers with a weight that can take a swap now, and the swap it takes: + /// the most it may, within its max swap, the excess balance and, for Spark, the room left in the node's Spark + /// wallet under its max balance. A provider is left out when that is below its min swap, and Spark also while + /// the node has no Spark wallet or its wallet is unavailable (the node's Max swaps in flight already limits its + /// swaps). All weights at 0 means Loop, as before. Null when no weighted provider is left, with whether the + /// excess balance was below every one's min swap. /// - private async Task<(SwapProvider? Provider, long SparkRoomSats)> SelectSwapProviderAsync(Node node) + private async Task<(SwapProvider? Provider, long AmountSats, bool BelowMinimum)> SelectSwapProviderAsync(Node node, + long excessBalance) { - var sparkRoom = 0L; var weights = new List<(SwapProvider Provider, int Weight)> { (SwapProvider.Loop, node.LoopSwapWeight), @@ -140,53 +141,77 @@ public async Task Execute(IJobExecutionContext context) if (weights.All(w => w.Weight <= 0)) { _logger.LogWarning("Node {NodeName} has total weight of 0, defaulting to Loop", node.Name); - return (SwapProvider.Loop, sparkRoom); + weights = [(SwapProvider.Loop, 1)]; } - if (node.SparkSwapWeight > 0) + var candidates = new List<(SwapProvider Provider, int Weight, long AmountSats)>(); + var belowMinimum = true; + foreach (var (provider, weight) in weights.Where(w => w.Weight > 0)) { - var (reason, room) = await SparkAvailabilityAsync(node); - sparkRoom = room; - if (reason is not null) + var limits = SwapAmountLimits.For(node, provider, _sparkSettings); + var amount = Math.Min(excessBalance, limits.MaxSats); + if (amount < limits.MinSats) { - _logger.LogInformation("Node {NodeName}: Spark cannot take a swap now ({Reason}), choosing among the other providers", - node.Name, reason); - weights.RemoveAll(w => w.Provider == SwapProvider.Spark); + _logger.LogDebug("Node {NodeName}: {Provider} left out, the excess balance ({Excess} sats) is below its min swap ({Min} sats)", + node.Name, provider, excessBalance, limits.MinSats); + continue; } + + belowMinimum = false; + if (provider == SwapProvider.Spark) + { + var (reason, room) = await SparkAvailabilityAsync(node, limits.MinSats); + if (reason is not null) + { + _logger.LogInformation("Node {NodeName}: Spark cannot take a swap now ({Reason}), choosing among the other providers", + node.Name, reason); + continue; + } + + // A Spark swap only takes what the node's Spark wallet can still hold under its max balance + if (amount > room) + { + _logger.LogInformation("Node {NodeName}: Spark swap clamped from {Amount} to {Room} sats by its Spark wallet's max balance", + node.Name, amount, room); + amount = room; + } + } + + candidates.Add((provider, weight, amount)); } - var candidates = weights.Where(w => w.Weight > 0).ToList(); - var totalWeight = candidates.Sum(w => w.Weight); - if (totalWeight == 0) + if (candidates.Count == 0) { - return (null, sparkRoom); + return (null, 0, belowMinimum); } // Weighted random selection + var totalWeight = candidates.Sum(c => c.Weight); var randomValue = Random.Shared.Next(0, totalWeight); - var selectedProvider = candidates[^1].Provider; + var selected = candidates[^1]; var cumulative = 0; - foreach (var (provider, weight) in candidates) + foreach (var candidate in candidates) { - cumulative += weight; + cumulative += candidate.Weight; if (randomValue < cumulative) { - selectedProvider = provider; + selected = candidate; break; } } _logger.LogDebug("Selected {Provider} for node {NodeName} (Loop weight: {LoopWeight}, 40swap weight: {FortySwapWeight}, Spark weight: {SparkWeight}, random: {Random})", - selectedProvider, node.Name, node.LoopSwapWeight, node.FortySwapWeight, node.SparkSwapWeight, randomValue); + selected.Provider, node.Name, node.LoopSwapWeight, node.FortySwapWeight, node.SparkSwapWeight, randomValue); - return (selectedProvider, sparkRoom); + return (selected.Provider, selected.AmountSats, false); } /// /// Why Spark can't take this node's swap now (null when it can), and how many sats the node's Spark wallet - /// can still take under its max balance, counting what its swaps not paid yet will still bring in + /// can still take under its max balance, counting what its swaps not paid yet will still bring in. Room below + /// (the node's Spark min swap) can't take a swap either. /// - private async Task<(string? Reason, long RoomSats)> SparkAvailabilityAsync(Node node) + private async Task<(string? Reason, long RoomSats)> SparkAvailabilityAsync(Node node, long minSwapSats) { if (_sparkSettings.WalletFor(node) is not { } sparkWallet) return ("the node has no Spark wallet", 0); @@ -200,8 +225,8 @@ public async Task Execute(IJobExecutionContext context) var unpaid = SparkGuardrails.UnpaidSats((await _swapOutRepository.GetAllPending()) .Where(s => string.Equals(s.SparkIdentity, status.IdentityPublicKey, StringComparison.OrdinalIgnoreCase))); var room = wallet.MaxBalanceSats - balance.Owned - balance.Incoming - unpaid; - return room < node.SwapMinAmountSats - ? ($"{wallet.Name} has {Math.Max(0, room)} sats of room under its max balance, below the node's minimum swap", room) + return room < minSwapSats + ? ($"{wallet.Name} has {Math.Max(0, room)} sats of room under its max balance, below the node's Spark min swap", room) : (null, room); } @@ -276,39 +301,22 @@ public async Task ManageNodeLiquidity(Node node, Canc return ManageNodeLiquidityResult.BudgetExhausted; } - // Determine swap amount (bounded by min, max, and excess balance) - // We can only swap what's above the threshold (excess balance) - var maxPossibleSwap = Math.Min(excessBalance, node.SwapMaxAmountSats); - - // If we don't have enough to meet the minimum swap size, skip - if (maxPossibleSwap < node.SwapMinAmountSats) - { - var maxPossibleSwapBtc = new Money(maxPossibleSwap, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC); - var minSwapBtc = new Money(node.SwapMinAmountSats, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC); - _logger.LogDebug("Node {NodeName} - max possible swap {MaxPossible} BTC is below minimum {Min} BTC. Excess: {Excess} BTC", - node.Name, maxPossibleSwapBtc, minSwapBtc, excessBalanceBtc); - return ManageNodeLiquidityResult.ExcessBalanceBelowMinimum; - } - - // Swap the maximum we can, ensuring it meets the minimum - var swapAmount = Math.Max(node.SwapMinAmountSats, maxPossibleSwap); - - // Select swap provider based on weights, before reserving an address for a swap that may not happen - var (provider, sparkRoom) = await SelectSwapProviderAsync(node); + // Pick the provider among those whose own min and max swap fit what's above the threshold (the excess + // balance), before reserving an address for a swap that may not happen: the swap is the most it takes + var (provider, swapAmount, belowMinimum) = await SelectSwapProviderAsync(node, excessBalance); if (provider is not { } selectedProvider) { + if (belowMinimum) + { + _logger.LogDebug("Node {NodeName} - excess balance {Excess} BTC is below the min swap of every weighted provider", + node.Name, excessBalanceBtc); + return ManageNodeLiquidityResult.ExcessBalanceBelowMinimum; + } + _logger.LogInformation("Node {NodeName}: no weighted swap provider can take a swap now, skipping", node.Name); return ManageNodeLiquidityResult.NoProviderAvailable; } - // A Spark swap only takes what the node's Spark wallet can still hold under its max balance - if (selectedProvider == SwapProvider.Spark && swapAmount > sparkRoom) - { - _logger.LogInformation("Node {NodeName}: Spark swap clamped from {Amount} to {Room} sats by its Spark wallet's max balance", - node.Name, swapAmount, sparkRoom); - swapAmount = sparkRoom; - } - var swapAmountBtc = new Money(swapAmount, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC); _logger.LogDebug("Node {NodeName} - Initiating swap for {Amount} BTC", node.Name, swapAmountBtc); diff --git a/src/Migrations/20261014115603_AddPerProviderSwapAmounts.Designer.cs b/src/Migrations/20261014115603_AddPerProviderSwapAmounts.Designer.cs new file mode 100644 index 00000000..9dfe7ef0 --- /dev/null +++ b/src/Migrations/20261014115603_AddPerProviderSwapAmounts.Designer.cs @@ -0,0 +1,2015 @@ +// +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("20261014115603_AddPerProviderSwapAmounts")] + partial class AddPerProviderSwapAmounts + { + /// + 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("FortySwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("FortySwapMinAmountSats") + .HasColumnType("bigint"); + + b.Property("FortySwapWeight") + .HasColumnType("integer"); + + b.Property("FundsDestinationWalletId") + .HasColumnType("integer"); + + b.Property("IsNodeDisabled") + .HasColumnType("boolean"); + + b.Property("LoopSwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("LoopSwapMinAmountSats") + .HasColumnType("bigint"); + + 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("SparkPaymentTimeoutMinutes") + .HasColumnType("integer"); + + b.Property("SparkSwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("SparkSwapMinAmountSats") + .HasColumnType("bigint"); + + b.Property("SparkSwapWeight") + .HasColumnType("integer"); + + b.Property("SparkWalletId") + .HasColumnType("integer"); + + b.Property("SwapBudgetRefreshInterval") + .HasColumnType("interval"); + + b.Property("SwapBudgetSats") + .HasColumnType("bigint"); + + b.Property("SwapBudgetStartDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FundsDestinationWalletId"); + + b.HasIndex("PubKey") + .IsUnique(); + + b.HasIndex("SparkWalletId"); + + 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("DestinationAddress") + .HasColumnType("text"); + + 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("PaymentHash") + .HasColumnType("text"); + + b.Property("PayoutSats") + .HasColumnType("bigint"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("ServiceFeeSats") + .HasColumnType("bigint"); + + b.Property("SparkIdentity") + .HasColumnType("text"); + + b.Property("SparkLeafIds") + .HasColumnType("text"); + + b.Property("SparkReceivedSats") + .HasColumnType("bigint"); + + b.Property("SparkTransferId") + .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.HasKey("Id"); + + b.HasIndex("DestinationWalletId"); + + b.HasIndex("NodeId"); + + b.HasIndex("ReferenceId") + .IsUnique() + .HasFilter("\"ReferenceId\" IS NOT NULL"); + + 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("Kind") + .HasColumnType("integer"); + + b.Property("MofN") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("SparkAccount") + .HasColumnType("integer"); + + b.Property("SparkEncryptedMnemonic") + .HasColumnType("text"); + + b.Property("SparkIdentityPublicKey") + .HasColumnType("text"); + + b.Property("SparkMaxBalanceSats") + .HasColumnType("bigint"); + + 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.HasOne("NodeGuard.Data.Models.Wallet", null) + .WithMany() + .HasForeignKey("SparkWalletId") + .OnDelete(DeleteBehavior.Restrict); + + 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/20261014115603_AddPerProviderSwapAmounts.cs b/src/Migrations/20261014115603_AddPerProviderSwapAmounts.cs new file mode 100644 index 00000000..78130b67 --- /dev/null +++ b/src/Migrations/20261014115603_AddPerProviderSwapAmounts.cs @@ -0,0 +1,84 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NodeGuard.Migrations +{ + /// + public partial class AddPerProviderSwapAmounts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "SwapMinAmountSats", + table: "Nodes", + newName: "LoopSwapMinAmountSats"); + + migrationBuilder.RenameColumn( + name: "SwapMaxAmountSats", + table: "Nodes", + newName: "LoopSwapMaxAmountSats"); + + migrationBuilder.AddColumn( + name: "FortySwapMaxAmountSats", + table: "Nodes", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "FortySwapMinAmountSats", + table: "Nodes", + type: "bigint", + nullable: false, + defaultValue: 0L); + + // The node's shared swap sizes become Loop's (the rename above) and 40swap's too + migrationBuilder.Sql( + "UPDATE \"Nodes\" SET \"FortySwapMinAmountSats\" = \"LoopSwapMinAmountSats\", \"FortySwapMaxAmountSats\" = \"LoopSwapMaxAmountSats\";"); + + migrationBuilder.AddColumn( + name: "SparkSwapMaxAmountSats", + table: "Nodes", + type: "bigint", + nullable: true); + + migrationBuilder.AddColumn( + name: "SparkSwapMinAmountSats", + table: "Nodes", + type: "bigint", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FortySwapMaxAmountSats", + table: "Nodes"); + + migrationBuilder.DropColumn( + name: "FortySwapMinAmountSats", + table: "Nodes"); + + migrationBuilder.DropColumn( + name: "SparkSwapMaxAmountSats", + table: "Nodes"); + + migrationBuilder.DropColumn( + name: "SparkSwapMinAmountSats", + table: "Nodes"); + + migrationBuilder.RenameColumn( + name: "LoopSwapMinAmountSats", + table: "Nodes", + newName: "SwapMinAmountSats"); + + migrationBuilder.RenameColumn( + name: "LoopSwapMaxAmountSats", + table: "Nodes", + newName: "SwapMaxAmountSats"); + } + } +} diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index f13ebf08..79769289 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1011,6 +1011,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("FortySwapEndpoint") .HasColumnType("text"); + b.Property("FortySwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("FortySwapMinAmountSats") + .HasColumnType("bigint"); + b.Property("FortySwapWeight") .HasColumnType("integer"); @@ -1020,6 +1026,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsNodeDisabled") .HasColumnType("boolean"); + b.Property("LoopSwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("LoopSwapMinAmountSats") + .HasColumnType("bigint"); + b.Property("LoopSwapWeight") .HasColumnType("integer"); @@ -1072,6 +1084,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("SparkPaymentTimeoutMinutes") .HasColumnType("integer"); + b.Property("SparkSwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("SparkSwapMinAmountSats") + .HasColumnType("bigint"); + b.Property("SparkSwapWeight") .HasColumnType("integer"); @@ -1087,12 +1105,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) 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"); diff --git a/src/Pages/Nodes.razor b/src/Pages/Nodes.razor index d65091ab..53ab4bc6 100644 --- a/src/Pages/Nodes.razor +++ b/src/Pages/Nodes.razor @@ -376,22 +376,23 @@ + @* Each provider has its own swap sizes: auto-liquidity picks the provider first, then swaps the most it takes *@ - - Min Swap Size - Minimum amount for each swap (must be ≥ @Constants.MINIMUM_SWAP_OUT_SIZE_BTC BTC) - + Loop Min Swap Size + Minimum amount for each Loop swap (≥ @Constants.MINIMUM_SWAP_OUT_SIZE_BTC BTC) + - Min swap size must be ≥ @Constants.MINIMUM_SWAP_OUT_SIZE_BTC BTC and ≤ max swap size + @@ -399,25 +400,112 @@ - - Max Swap Size - Maximum amount for each swap (must be ≤ @Constants.MAXIMUM_SWAP_OUT_SIZE_BTC BTC) - + Loop Max Swap Size + Maximum amount for each Loop swap (≤ @Constants.MAXIMUM_SWAP_OUT_SIZE_BTC BTC) + + + + + + + + + + + + + + 40swap Min Swap Size + Minimum amount for each 40swap swap (≥ @Constants.MINIMUM_SWAP_OUT_SIZE_BTC BTC) + - Max swap size must be ≤ @Constants.MAXIMUM_SWAP_OUT_SIZE_BTC BTC and ≥ min swap size + + + + + + + + + + 40swap Max Swap Size + Maximum amount for each 40swap swap (≤ @Constants.MAXIMUM_SWAP_OUT_SIZE_BTC BTC) + + + + @if (SparkSettings.Enabled || _selectedNodeForLiquidity.SparkSwapWeight > 0) + { + + + + + Spark Min Swap Size + Minimum amount for each Spark swap (≥ @SparkLowestMinSwapBtc BTC; empty: @SatsToBtc(SparkSettings.SwapMinSats) BTC, SPARK_SWAP_MIN_SATS) + + + + + + + + + + + + Spark Max Swap Size + Maximum amount for each Spark swap (≤ @Constants.MAXIMUM_SWAP_OUT_SIZE_BTC BTC; empty: @SatsToBtc(SparkSettings.SwapMaxSats) BTC, SPARK_SWAP_MAX_SATS). A Spark swap also fits its wallet's max balance + + + + + + + + + + } @@ -668,8 +756,20 @@ private Validations? _nodeLiquidityValidationsRef; // BTC conversion fields for easier editing - private decimal _minSwapSizeBtc; - private decimal _maxSwapSizeBtc; + private decimal _loopMinSwapBtc; + private decimal _loopMaxSwapBtc; + private decimal _fortySwapMinSwapBtc; + private decimal _fortySwapMaxSwapBtc; + // Empty: SPARK_SWAP_MIN_SATS / SPARK_SWAP_MAX_SATS + private decimal? _sparkMinSwapBtc; + private decimal? _sparkMaxSwapBtc; + + private decimal SparkMinSwapBtc => _sparkMinSwapBtc ?? SatsToBtc(SparkSettings.SwapMinSats); + private decimal SparkMaxSwapBtc => _sparkMaxSwapBtc ?? SatsToBtc(SparkSettings.SwapMaxSats); + private static decimal SparkLowestMinSwapBtc => SatsToBtc(SparkSettings.LowestSwapMinSats); + + private static decimal SatsToBtc(long sats) => (decimal)new NBitcoin.Money(sats).ToUnit(NBitcoin.MoneyUnit.BTC); + private static long BtcToSats(decimal btc) => (long)new NBitcoin.Money(btc, NBitcoin.MoneyUnit.BTC).Satoshi; private decimal _balanceThresholdBtc; private decimal _swapBudgetBtc; private int _budgetRefreshDays; @@ -1029,8 +1129,12 @@ } // Convert sats to BTC for display - _minSwapSizeBtc = (decimal)new NBitcoin.Money(node.SwapMinAmountSats).ToUnit(NBitcoin.MoneyUnit.BTC); - _maxSwapSizeBtc = (decimal)new NBitcoin.Money(node.SwapMaxAmountSats).ToUnit(NBitcoin.MoneyUnit.BTC); + _loopMinSwapBtc = SatsToBtc(node.LoopSwapMinAmountSats); + _loopMaxSwapBtc = SatsToBtc(node.LoopSwapMaxAmountSats); + _fortySwapMinSwapBtc = SatsToBtc(node.FortySwapMinAmountSats); + _fortySwapMaxSwapBtc = SatsToBtc(node.FortySwapMaxAmountSats); + _sparkMinSwapBtc = node.SparkSwapMinAmountSats is { } sparkMin ? SatsToBtc(sparkMin) : null; + _sparkMaxSwapBtc = node.SparkSwapMaxAmountSats is { } sparkMax ? SatsToBtc(sparkMax) : null; _balanceThresholdBtc = (decimal)new NBitcoin.Money(node.MinimumBalanceThresholdSats).ToUnit(NBitcoin.MoneyUnit.BTC); _swapBudgetBtc = (decimal)new NBitcoin.Money(node.SwapBudgetSats).ToUnit(NBitcoin.MoneyUnit.BTC); _budgetRefreshDays = node.SwapBudgetRefreshInterval.Days; @@ -1078,8 +1182,12 @@ } // Convert BTC back to sats and percent back to ratio (0-1) - _selectedNodeForLiquidity.SwapMinAmountSats = (long)new NBitcoin.Money(_minSwapSizeBtc, NBitcoin.MoneyUnit.BTC).Satoshi; - _selectedNodeForLiquidity.SwapMaxAmountSats = (long)new NBitcoin.Money(_maxSwapSizeBtc, NBitcoin.MoneyUnit.BTC).Satoshi; + _selectedNodeForLiquidity.LoopSwapMinAmountSats = BtcToSats(_loopMinSwapBtc); + _selectedNodeForLiquidity.LoopSwapMaxAmountSats = BtcToSats(_loopMaxSwapBtc); + _selectedNodeForLiquidity.FortySwapMinAmountSats = BtcToSats(_fortySwapMinSwapBtc); + _selectedNodeForLiquidity.FortySwapMaxAmountSats = BtcToSats(_fortySwapMaxSwapBtc); + _selectedNodeForLiquidity.SparkSwapMinAmountSats = _sparkMinSwapBtc is { } sparkMin ? BtcToSats(sparkMin) : null; + _selectedNodeForLiquidity.SparkSwapMaxAmountSats = _sparkMaxSwapBtc is { } sparkMax ? BtcToSats(sparkMax) : null; _selectedNodeForLiquidity.MinimumBalanceThresholdSats = (long)new NBitcoin.Money(_balanceThresholdBtc, NBitcoin.MoneyUnit.BTC).Satoshi; _selectedNodeForLiquidity.SwapBudgetSats = (long)new NBitcoin.Money(_swapBudgetBtc, NBitcoin.MoneyUnit.BTC).Satoshi; _selectedNodeForLiquidity.SwapBudgetRefreshInterval = TimeSpan.FromDays(_budgetRefreshDays); @@ -1129,39 +1237,34 @@ } // Validation methods - private void ValidateMinSwapSize(ValidatorEventArgs arg) + /// + /// One end of a provider's swap sizes: the min at least , the max at most + /// MAXIMUM_SWAP_OUT_SIZE_BTC, and the min no more than the max + /// + private static void ValidateSwapSize(ValidatorEventArgs arg, decimal min, decimal max, decimal lowestMin, bool isMin) { arg.Status = ValidationStatus.Success; - - if (_minSwapSizeBtc < Constants.MINIMUM_SWAP_OUT_SIZE_BTC) + + if (isMin && min < lowestMin) { arg.Status = ValidationStatus.Error; - arg.ErrorText = $"Min swap size must be at least {Constants.MINIMUM_SWAP_OUT_SIZE_BTC} BTC"; + arg.ErrorText = $"Min swap size must be at least {lowestMin} BTC"; return; } - - if (_minSwapSizeBtc > _maxSwapSizeBtc) - { - arg.Status = ValidationStatus.Error; - arg.ErrorText = "Min swap size must be less than or equal to max swap size"; - } - } - private void ValidateMaxSwapSize(ValidatorEventArgs arg) - { - arg.Status = ValidationStatus.Success; - - if (_maxSwapSizeBtc > Constants.MAXIMUM_SWAP_OUT_SIZE_BTC) + if (!isMin && max > Constants.MAXIMUM_SWAP_OUT_SIZE_BTC) { arg.Status = ValidationStatus.Error; arg.ErrorText = $"Max swap size must be at most {Constants.MAXIMUM_SWAP_OUT_SIZE_BTC} BTC"; return; } - - if (_maxSwapSizeBtc < _minSwapSizeBtc) + + if (min > max) { arg.Status = ValidationStatus.Error; - arg.ErrorText = "Max swap size must be greater than or equal to min swap size"; + arg.ErrorText = isMin + ? "Min swap size must be less than or equal to max swap size" + : "Max swap size must be greater than or equal to min swap size"; } } diff --git a/src/Services/Spark/SparkSettings.cs b/src/Services/Spark/SparkSettings.cs index f3473c3f..c6c3961a 100644 --- a/src/Services/Spark/SparkSettings.cs +++ b/src/Services/Spark/SparkSettings.cs @@ -67,6 +67,15 @@ public sealed class SparkSettings public const int MaxPaymentTimeoutMinutes = 25; public const long DefaultMaxBalanceSats = 10_000_000; + /// A node's smallest Spark swap by default (SPARK_SWAP_MIN_SATS). + public const long DefaultSwapMinSats = 50_000; + + /// A node's largest Spark swap by default (SPARK_SWAP_MAX_SATS). + public const long DefaultSwapMaxSats = 2_000_000; + + /// The smallest Spark min swap a node may set: smaller swaps aren't worth their routing and exit fees. + public const long LowestSwapMinSats = 10_000; + public static readonly SparkSettings Disabled = new(); public bool Enabled { get; init; } @@ -155,6 +164,12 @@ public sealed class SparkSettings /// public long MaxBalanceSats { get; init; } = DefaultMaxBalanceSats; + /// A node's smallest Spark swap (SPARK_SWAP_MIN_SATS), unless the node has its own. + public long SwapMinSats { get; init; } = DefaultSwapMinSats; + + /// A node's largest Spark swap (SPARK_SWAP_MAX_SATS), unless the node has its own. + public long SwapMaxSats { get; init; } = DefaultSwapMaxSats; + /// The remote signer's Function URL (REMOTE_SIGNER_ENDPOINT), signed with SigV4. public Uri? SignerEndpoint { get; init; } @@ -324,6 +339,21 @@ public static SparkSettings FromEnvironment(Func env, Network n errors.Add("SPARK_MAX_BALANCE_SATS must be a positive number of sats"); } + var largestSwap = (long)(Constants.MAXIMUM_SWAP_OUT_SIZE_BTC * 100_000_000m); + var swapMin = DefaultSwapMinSats; + if (env("SPARK_SWAP_MIN_SATS") is { Length: > 0 } swapMinValue && + (!long.TryParse(swapMinValue, out swapMin) || swapMin < LowestSwapMinSats)) + { + errors.Add($"SPARK_SWAP_MIN_SATS must be a number of sats of at least {LowestSwapMinSats}"); + } + + var swapMax = DefaultSwapMaxSats; + if (env("SPARK_SWAP_MAX_SATS") is { Length: > 0 } swapMaxValue && + (!long.TryParse(swapMaxValue, out swapMax) || swapMax < swapMin || swapMax > largestSwap)) + { + errors.Add($"SPARK_SWAP_MAX_SATS must be a number of sats from SPARK_SWAP_MIN_SATS to {largestSwap} (MAXIMUM_SWAP_OUT_SIZE_BTC)"); + } + Uri? endpoint = null; Uri? rieUrl = null; if (mode == SparkSignerMode.Remote) @@ -364,6 +394,8 @@ public static SparkSettings FromEnvironment(Func env, Network n PaymentTimeoutMinutes = paymentTimeout, ExitSpeed = exitSpeed, MaxBalanceSats = maxBalance, + SwapMinSats = swapMin, + SwapMaxSats = swapMax, SignerEndpoint = endpoint, SignerRieUrl = rieUrl }; diff --git a/src/Services/SwapAmountLimits.cs b/src/Services/SwapAmountLimits.cs new file mode 100644 index 00000000..c87bbcc7 --- /dev/null +++ b/src/Services/SwapAmountLimits.cs @@ -0,0 +1,39 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using NodeGuard.Data.Models; +using NodeGuard.Services.Spark; + +namespace NodeGuard.Services; + +/// The smallest and largest swap-out a node makes through a provider. +public readonly record struct SwapAmountLimits(long MinSats, long MaxSats) +{ + /// + /// 's limits for : Loop's and 40swap's are the node's own; + /// Spark's are the node's own, else SPARK_SWAP_MIN_SATS / SPARK_SWAP_MAX_SATS. + /// + public static SwapAmountLimits For(Node node, SwapProvider provider, SparkSettings spark) => provider switch + { + SwapProvider.Loop => new(node.LoopSwapMinAmountSats, node.LoopSwapMaxAmountSats), + SwapProvider.FortySwap => new(node.FortySwapMinAmountSats, node.FortySwapMaxAmountSats), + SwapProvider.Spark => new(node.SparkSwapMinAmountSats ?? spark.SwapMinSats, node.SparkSwapMaxAmountSats ?? spark.SwapMaxSats), + _ => throw new ArgumentOutOfRangeException(nameof(provider), provider, "Unknown swap provider") + }; +} diff --git a/test/NodeGuard.Tests/Jobs/AutoLiquidityManagementJobTests.cs b/test/NodeGuard.Tests/Jobs/AutoLiquidityManagementJobTests.cs index ecbea7d2..f277aa40 100644 --- a/test/NodeGuard.Tests/Jobs/AutoLiquidityManagementJobTests.cs +++ b/test/NodeGuard.Tests/Jobs/AutoLiquidityManagementJobTests.cs @@ -109,8 +109,13 @@ private Node CreateTestNode( IsNodeDisabled = isDisabled, AutoLiquidityManagementEnabled = autoLiquidityEnabled, FundsDestinationWalletId = destinationWalletId, - SwapMinAmountSats = minSwapSats, - SwapMaxAmountSats = maxSwapSats, + // The same sizes for every provider, unless a test sets its own + LoopSwapMinAmountSats = minSwapSats, + LoopSwapMaxAmountSats = maxSwapSats, + FortySwapMinAmountSats = minSwapSats, + FortySwapMaxAmountSats = maxSwapSats, + SparkSwapMinAmountSats = minSwapSats, + SparkSwapMaxAmountSats = maxSwapSats, MaxSwapsInFlight = maxSwapsInFlight, MaxSwapRoutingFeeRatio = maxSwapFeeRatio, MinimumBalanceThresholdSats = minBalanceThresholdSats, @@ -687,7 +692,7 @@ public async Task ManageNodeLiquidity_WithSpark_TheRoomLeavesOutWhatUnpaidSwapsW public async Task ManageNodeLiquidity_WithOnlySparkWeight_WhenTheRoomIsBelowTheNodeMinimum_SkipsWithoutReservingAnAddress() { var (node, _) = ArrangeSwappableNode(loopWeight: 0, fortySwapWeight: 0, sparkWeight: 100); - SetSparkBalance(owned: SparkMaxBalanceSats - node.SwapMinAmountSats + 1); + SetSparkBalance(owned: SparkMaxBalanceSats - node.SparkSwapMinAmountSats!.Value + 1); var result = await _autoLiquidityManagementJob.ManageNodeLiquidity(node, CancellationToken.None); @@ -710,6 +715,68 @@ public async Task ManageNodeLiquidity_WhenSparkHasNoRoom_UsesTheOtherWeightedPro It.IsAny(), It.IsAny()), Times.Once); } + [Fact] + public async Task ManageNodeLiquidity_SizesTheSwapWithTheChosenProvidersOwnMax() + { + var (node, _) = ArrangeSwappableNode(loopWeight: 0, fortySwapWeight: 0, sparkWeight: 100); + node.SparkSwapMaxAmountSats = 2_000_000; + + var result = await _autoLiquidityManagementJob.ManageNodeLiquidity(node, CancellationToken.None); + + result.Should().Be(ManageNodeLiquidityResult.Success); + _swapsServiceMock.Verify(x => x.CreateSwapOutAsync(node, + It.Is(s => s.Provider == SwapProvider.Spark && s.SatsAmount == 2_000_000), + It.Is(r => r.Amount == 2_000_000), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ManageNodeLiquidity_WithoutItsOwnSparkSizes_ANodeUsesTheSparkSettings() + { + var (node, _) = ArrangeSwappableNode(loopWeight: 0, fortySwapWeight: 0, sparkWeight: 100); + node.SparkSwapMinAmountSats = null; + node.SparkSwapMaxAmountSats = null; + + await _autoLiquidityManagementJob.ManageNodeLiquidity(node, CancellationToken.None); + + _swapsServiceMock.Verify(x => x.CreateSwapOutAsync(node, + It.Is(s => s.Provider == SwapProvider.Spark && s.SatsAmount == SparkSettings.DefaultSwapMaxSats), + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ManageNodeLiquidity_AProviderWhoseMinIsAboveTheExcess_IsLeftOut_AndAnotherSwaps() + { + // 50M sats above the threshold: below Loop's min, within Spark's + var (node, _) = ArrangeSwappableNode(loopWeight: 50, fortySwapWeight: 0, sparkWeight: 50); + node.LoopSwapMinAmountSats = 60_000_000; + node.LoopSwapMaxAmountSats = 80_000_000; + node.SparkSwapMaxAmountSats = 2_000_000; + + var result = await _autoLiquidityManagementJob.ManageNodeLiquidity(node, CancellationToken.None); + + result.Should().Be(ManageNodeLiquidityResult.Success); + _swapsServiceMock.Verify(x => x.CreateSwapOutAsync(node, + It.Is(s => s.Provider == SwapProvider.Spark && s.SatsAmount == 2_000_000), + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ManageNodeLiquidity_WhenTheExcessIsBelowEveryWeightedProvidersMin_IsBelowMinimum_WithoutAskingSpark() + { + var (node, _) = ArrangeSwappableNode(loopWeight: 50, fortySwapWeight: 0, sparkWeight: 50); + node.LoopSwapMinAmountSats = 60_000_000; + node.LoopSwapMaxAmountSats = 80_000_000; + node.SparkSwapMinAmountSats = 60_000_000; + node.SparkSwapMaxAmountSats = 80_000_000; + + var result = await _autoLiquidityManagementJob.ManageNodeLiquidity(node, CancellationToken.None); + + result.Should().Be(ManageNodeLiquidityResult.ExcessBalanceBelowMinimum); + _sparkWalletMock.Verify(x => x.GetBalanceAsync(It.IsAny(), It.IsAny()), Times.Never); + _swapsServiceMock.Verify(x => x.CreateSwapOutAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + [Fact] public async Task ManageNodeLiquidity_WithAllWeightsAtZero_StillDefaultsToLoop() { diff --git a/test/NodeGuard.Tests/Services/Spark/SparkSettingsTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkSettingsTests.cs index cfffbc94..b785b381 100644 --- a/test/NodeGuard.Tests/Services/Spark/SparkSettingsTests.cs +++ b/test/NodeGuard.Tests/Services/Spark/SparkSettingsTests.cs @@ -141,6 +141,8 @@ public void OnMainnet_LightsparksOperatorsAndSspAreTheDefaults() settings.ExitSpeed.Should().Be(ExitSpeed.Slow, "the cheapest exit"); settings.ExitOverdueAfter.Should().Be(TimeSpan.FromHours(24)); settings.MaxBalanceSats.Should().Be(SparkSettings.DefaultMaxBalanceSats); + settings.SwapMinSats.Should().Be(SparkSettings.DefaultSwapMinSats); + settings.SwapMaxSats.Should().Be(SparkSettings.DefaultSwapMaxSats); settings.SignerEndpoint.Should().Be(new Uri("https://abc.lambda-url.eu-central-1.on.aws/")); var options = settings.ToSparkOptions(null); @@ -168,6 +170,8 @@ public void OnRegtest_TheConfiguredNetworkIsRead() env["SPARK_PAYMENT_TIMEOUT_MINUTES"] = "15"; env["SPARK_EXIT_SPEED"] = "fast"; env["SPARK_MAX_BALANCE_SATS"] = "2000000"; + env["SPARK_SWAP_MIN_SATS"] = "20000"; + env["SPARK_SWAP_MAX_SATS"] = "1500000"; var settings = Read(env, Network.RegTest); @@ -183,6 +187,8 @@ public void OnRegtest_TheConfiguredNetworkIsRead() settings.ExitSpeed.Should().Be(ExitSpeed.Fast); settings.ExitOverdueAfter.Should().Be(TimeSpan.FromHours(6)); settings.MaxBalanceSats.Should().Be(2_000_000); + settings.SwapMinSats.Should().Be(20_000); + settings.SwapMaxSats.Should().Be(1_500_000); settings.ToSparkOptions(SspIdentity).EffectiveSspIdentityPublicKeyHex.Should().Be(SspIdentity); } @@ -206,6 +212,10 @@ public void OnRegtest_TheConfiguredNetworkIsRead() [InlineData("SPARK_PAYMENT_TIMEOUT_MINUTES", "ten", "SPARK_PAYMENT_TIMEOUT_MINUTES")] [InlineData("SPARK_EXIT_SPEED", "SLOWEST", "SPARK_EXIT_SPEED must be FAST, MEDIUM or SLOW")] [InlineData("SPARK_MAX_BALANCE_SATS", "-5", "SPARK_MAX_BALANCE_SATS")] + [InlineData("SPARK_SWAP_MIN_SATS", "9999", "SPARK_SWAP_MIN_SATS must be a number of sats of at least 10000")] + [InlineData("SPARK_SWAP_MIN_SATS", "lots", "SPARK_SWAP_MIN_SATS")] + [InlineData("SPARK_SWAP_MAX_SATS", "40000", "SPARK_SWAP_MAX_SATS must be a number of sats from SPARK_SWAP_MIN_SATS")] + [InlineData("SPARK_SWAP_MAX_SATS", "50000001", "SPARK_SWAP_MAX_SATS")] public void InvalidSettings_FailTheStartup(string variable, string? value, string expected) { var env = Remote(); From 0452a9e8cd276408e6a2f1796fe0e1190c542ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:00:39 +0200 Subject: [PATCH 02/10] Rename the payment retry settings SEND_PAYMENT_*, for Spark swaps too REBALANCE_MAX_ATTEMPTS, REBALANCE_AMOUNT_BACKOFF_RATIO, REBALANCE_INITIAL_RETRY_DELAY_SECONDS and REBALANCE_RETRY_BACKOFF_MULTIPLIER become SEND_PAYMENT_*, like SEND_PAYMENT_MAX_PARTS: Spark swap payments will retry with them too. The old names are still read as fallbacks. --- docs/rebalance-algorithm.md | 8 +-- src/Data/Models/Rebalance.cs | 4 +- src/Helpers/Constants.cs | 50 +++++++++++-------- src/Proto/nodeguard.proto | 4 +- src/Services/RebalanceService.cs | 16 +++--- src/Shared/NewRebalanceModal.razor | 12 ++--- .../Services/RebalanceServiceTests.cs | 8 +-- 7 files changed, 55 insertions(+), 47 deletions(-) diff --git a/docs/rebalance-algorithm.md b/docs/rebalance-algorithm.md index ec4f5a10..46b5d7af 100644 --- a/docs/rebalance-algorithm.md +++ b/docs/rebalance-algorithm.md @@ -336,10 +336,10 @@ which one gets drained can differ between runs on an otherwise unchanged node. | Constant | Default | Effect | |---|---|---| | `DEFAULT_REBALANCE_TIMEOUT_SECONDS` | `180` | Payment timeout when the caller supplies none. | -| `REBALANCE_MAX_ATTEMPTS` | `3` | Attempts per rebalance. | -| `REBALANCE_INITIAL_RETRY_DELAY_SECONDS` | `60` | First retry delay. | -| `REBALANCE_RETRY_BACKOFF_MULTIPLIER` | `2.0` | `delay = 60 · 2^(attempt−2)` ⇒ 60 s, 120 s. | -| `REBALANCE_AMOUNT_BACKOFF_RATIO` | `0.8` | Retries shrink the amount by this factor per attempt. | +| `SEND_PAYMENT_MAX_ATTEMPTS` | `3` | Attempts per rebalance or Spark swap payment. `REBALANCE_MAX_ATTEMPTS` is still read as a fallback. | +| `SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS` | `60` | First retry delay. `REBALANCE_INITIAL_RETRY_DELAY_SECONDS` is still read as a fallback. | +| `SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER` | `2.0` | `delay = 60 · 2^(attempt−2)` ⇒ 60 s, 120 s. `REBALANCE_RETRY_BACKOFF_MULTIPLIER` is still read as a fallback. | +| `SEND_PAYMENT_AMOUNT_BACKOFF_RATIO` | `0.8` | Retries shrink the amount by this factor per attempt. `REBALANCE_AMOUNT_BACKOFF_RATIO` is still read as a fallback. | | `SEND_PAYMENT_MAX_PARTS` | `32` | Max MPP shards of a payment sent with SendPaymentV2: rebalances and Spark swaps. `REBALANCE_MAX_PARTS` is still read as a fallback. | | `REBALANCE_DEFAULT_MAX_FEE_PCT` | `0.05` | Fallback cap when none is supplied (0.05 %). | | `REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT` | `0.05` | Fallback retry cap. | diff --git a/src/Data/Models/Rebalance.cs b/src/Data/Models/Rebalance.cs index 8f94f2bd..4e80896e 100644 --- a/src/Data/Models/Rebalance.cs +++ b/src/Data/Models/Rebalance.cs @@ -143,13 +143,13 @@ public static long WorstCaseFeeSats(long amountSats, double maxFeePct) /// 1 = never shrink (every attempt retries the full requested amount); 0.8 = each retry is /// 20% smaller; 0.5 = halve each time. The amount for attempt n is /// RequestedAmountSats × ratio^(n-1), floored at Constants.REBALANCE_MIN_AMOUNT_SATS. - /// When null, the runtime falls back to Constants.REBALANCE_AMOUNT_BACKOFF_RATIO. + /// When null, the runtime falls back to Constants.SEND_PAYMENT_AMOUNT_BACKOFF_RATIO. /// public double? AmountBackoffRatio { get; set; } /// /// Maximum number of attempts (including the first try). When null, falls back to - /// Constants.REBALANCE_MAX_ATTEMPTS + /// Constants.SEND_PAYMENT_MAX_ATTEMPTS /// public int? MaxAttempts { get; set; } diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index 5673f35b..fe20434c 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -167,21 +167,24 @@ public class Constants public static decimal REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT = 0.05m; /// - /// Maximum number of attempts for a rebalance, including the first try. + /// Maximum number of attempts for a payment sent with SendPaymentV2, including the first try: rebalances and + /// Spark swap payments (SEND_PAYMENT_MAX_ATTEMPTS; REBALANCE_MAX_ATTEMPTS still read as a fallback). /// - public static int REBALANCE_MAX_ATTEMPTS = 3; + public static int SEND_PAYMENT_MAX_ATTEMPTS = 3; /// - /// Delay before the first retry attempt, in seconds. Subsequent attempts use - /// REBALANCE_RETRY_BACKOFF_MULTIPLIER for exponential backoff. + /// Delay before the first retry attempt, in seconds (SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS; + /// REBALANCE_INITIAL_RETRY_DELAY_SECONDS still read as a fallback). Subsequent attempts use + /// SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER for exponential backoff. /// - public static int REBALANCE_INITIAL_RETRY_DELAY_SECONDS = 60; + public static int SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS = 60; /// - /// Multiplier applied to the retry delay for each subsequent retry. With the default - /// 2.0 and an initial delay of 60s, retries fire at 60s, 120s, 240s, ... + /// Multiplier applied to the retry delay for each subsequent retry (SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER; + /// REBALANCE_RETRY_BACKOFF_MULTIPLIER still read as a fallback). With the default 2.0 and an initial delay of + /// 60s, retries fire at 60s, 120s, 240s, ... /// - public static double REBALANCE_RETRY_BACKOFF_MULTIPLIER = 2.0; + public static double SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER = 2.0; /// /// Smallest amount a rebalance attempt will shrink down to before giving up (the floor @@ -190,13 +193,14 @@ public class Constants public static long REBALANCE_MIN_AMOUNT_SATS = 10_000; /// - /// Multiplier applied to the rebalanced amount on each retry attempt. Range: (0, 1]. + /// Multiplier applied to the paid amount on each retry attempt, for rebalances and Spark swap payments + /// (SEND_PAYMENT_AMOUNT_BACKOFF_RATIO; REBALANCE_AMOUNT_BACKOFF_RATIO still read as a fallback). Range: (0, 1]. /// 1 = never shrink (every attempt retries the full requested amount); 0.8 = each retry - /// is 20% smaller than the previous attempt (partial rebalancing); 0.5 = halve each time. - /// The amount for attempt n is RequestedAmountSats × ratio^(n-1), floored at - /// REBALANCE_MIN_AMOUNT_SATS. + /// is 20% smaller than the previous attempt; 0.5 = halve each time. + /// A rebalance's amount for attempt n is RequestedAmountSats × ratio^(n-1), floored at + /// REBALANCE_MIN_AMOUNT_SATS; a Spark swap's is floored at the node's Spark min swap. /// - public static double REBALANCE_AMOUNT_BACKOFF_RATIO = 0.8; + public static double SEND_PAYMENT_AMOUNT_BACKOFF_RATIO = 0.8; /// /// Maximum number of partial payments (MPP shards) LND may split a payment sent with SendPaymentV2 into: @@ -594,20 +598,24 @@ static Constants() var rebRetryPct = Environment.GetEnvironmentVariable("REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT"); if (rebRetryPct != null) REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT = decimal.Parse(rebRetryPct, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture); - var rebMaxAttempts = Environment.GetEnvironmentVariable("REBALANCE_MAX_ATTEMPTS"); - if (rebMaxAttempts != null) REBALANCE_MAX_ATTEMPTS = int.Parse(rebMaxAttempts); + var sendPaymentMaxAttempts = Environment.GetEnvironmentVariable("SEND_PAYMENT_MAX_ATTEMPTS") + ?? Environment.GetEnvironmentVariable("REBALANCE_MAX_ATTEMPTS"); + if (sendPaymentMaxAttempts != null) SEND_PAYMENT_MAX_ATTEMPTS = int.Parse(sendPaymentMaxAttempts); - var rebInitialDelay = Environment.GetEnvironmentVariable("REBALANCE_INITIAL_RETRY_DELAY_SECONDS"); - if (rebInitialDelay != null) REBALANCE_INITIAL_RETRY_DELAY_SECONDS = int.Parse(rebInitialDelay); + var sendPaymentInitialDelay = Environment.GetEnvironmentVariable("SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS") + ?? Environment.GetEnvironmentVariable("REBALANCE_INITIAL_RETRY_DELAY_SECONDS"); + if (sendPaymentInitialDelay != null) SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS = int.Parse(sendPaymentInitialDelay); - var rebBackoff = Environment.GetEnvironmentVariable("REBALANCE_RETRY_BACKOFF_MULTIPLIER"); - if (rebBackoff != null) REBALANCE_RETRY_BACKOFF_MULTIPLIER = double.Parse(rebBackoff, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture); + var sendPaymentBackoff = Environment.GetEnvironmentVariable("SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER") + ?? Environment.GetEnvironmentVariable("REBALANCE_RETRY_BACKOFF_MULTIPLIER"); + if (sendPaymentBackoff != null) SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER = double.Parse(sendPaymentBackoff, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture); var rebMinAmount = Environment.GetEnvironmentVariable("REBALANCE_MIN_AMOUNT_SATS"); if (rebMinAmount != null) REBALANCE_MIN_AMOUNT_SATS = long.Parse(rebMinAmount); - var rebProbeBackoff = Environment.GetEnvironmentVariable("REBALANCE_AMOUNT_BACKOFF_RATIO"); - if (rebProbeBackoff != null) REBALANCE_AMOUNT_BACKOFF_RATIO = double.Parse(rebProbeBackoff, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture); + var sendPaymentAmountBackoff = Environment.GetEnvironmentVariable("SEND_PAYMENT_AMOUNT_BACKOFF_RATIO") + ?? Environment.GetEnvironmentVariable("REBALANCE_AMOUNT_BACKOFF_RATIO"); + if (sendPaymentAmountBackoff != null) SEND_PAYMENT_AMOUNT_BACKOFF_RATIO = double.Parse(sendPaymentAmountBackoff, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture); var sendPaymentMaxParts = Environment.GetEnvironmentVariable("SEND_PAYMENT_MAX_PARTS") ?? Environment.GetEnvironmentVariable("REBALANCE_MAX_PARTS"); diff --git a/src/Proto/nodeguard.proto b/src/Proto/nodeguard.proto index 37cef55f..0b8ebeaf 100644 --- a/src/Proto/nodeguard.proto +++ b/src/Proto/nodeguard.proto @@ -643,10 +643,10 @@ message RequestRebalanceRequest { // 1 = never shrink (every attempt retries the full amount); 0.8 = each retry is 20% // smaller; 0.5 = halve each time. The amount for attempt n is amount_sats * ratio^(n-1), // floored at REBALANCE_MIN_AMOUNT_SATS. If unset, falls back to - // Constants.REBALANCE_AMOUNT_BACKOFF_RATIO. + // Constants.SEND_PAYMENT_AMOUNT_BACKOFF_RATIO. optional double amount_backoff_ratio = 8; // Maximum number of attempts (including the first try). Must be >= 1. - // If unset, falls back to Constants.REBALANCE_MAX_ATTEMPTS + // If unset, falls back to Constants.SEND_PAYMENT_MAX_ATTEMPTS optional int32 max_attempts = 9; } diff --git a/src/Services/RebalanceService.cs b/src/Services/RebalanceService.cs index 28838af2..794b165c 100644 --- a/src/Services/RebalanceService.cs +++ b/src/Services/RebalanceService.cs @@ -211,7 +211,7 @@ public async Task ExecuteAsync(int rebalanceId, CancellationToken ct _logger.LogInformation( "Executing rebalance {RebalanceId} attempt {Attempt}/{MaxAttempts} (node={NodeId} '{NodeName}', amount={AmountSats} sats, maxFeePct={MaxFeePct}, timeoutSeconds={TimeoutSeconds})", - rebalance.Id, rebalance.AttemptNumber, rebalance.MaxAttempts ?? Constants.REBALANCE_MAX_ATTEMPTS, + rebalance.Id, rebalance.AttemptNumber, rebalance.MaxAttempts ?? Constants.SEND_PAYMENT_MAX_ATTEMPTS, node.Id, node.Name, rebalance.SatsAmount, rebalance.MaxFeePct, rebalance.TimeoutSeconds); try @@ -407,7 +407,7 @@ private static long ComputeFeeLimitMsat(long satsAmount, double maxFeePct) /// private static long ComputeAttemptAmountSats(Rebalance rebalance) { - var ratio = rebalance.AmountBackoffRatio ?? Constants.REBALANCE_AMOUNT_BACKOFF_RATIO; + var ratio = rebalance.AmountBackoffRatio ?? Constants.SEND_PAYMENT_AMOUNT_BACKOFF_RATIO; var scaled = (long)Math.Round( rebalance.RequestedAmountSats * Math.Pow(ratio, rebalance.AttemptNumber - 1), MidpointRounding.AwayFromZero); @@ -424,9 +424,9 @@ private static long ComputeAttemptAmountSats(Rebalance rebalance) /// private static long ComputeInvoiceExpirySeconds(Rebalance rebalance) { - var maxAttempts = Math.Max(1, rebalance.MaxAttempts ?? Constants.REBALANCE_MAX_ATTEMPTS); - var initialDelay = Constants.REBALANCE_INITIAL_RETRY_DELAY_SECONDS; - var multiplier = Constants.REBALANCE_RETRY_BACKOFF_MULTIPLIER; + var maxAttempts = Math.Max(1, rebalance.MaxAttempts ?? Constants.SEND_PAYMENT_MAX_ATTEMPTS); + var initialDelay = Constants.SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS; + var multiplier = Constants.SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER; // Mirrors ScheduleRetryIfEligibleAsync: delay between attempt N and N+1 is // initialDelay * multiplier^(N-1). Sum across all retries we might schedule. @@ -477,7 +477,7 @@ or RebalanceStatus.InsufficientBalance return; } - var maxAttempts = rebalance.MaxAttempts ?? Constants.REBALANCE_MAX_ATTEMPTS; + var maxAttempts = rebalance.MaxAttempts ?? Constants.SEND_PAYMENT_MAX_ATTEMPTS; if (rebalance.AttemptNumber >= maxAttempts) { _logger.LogInformation( @@ -497,8 +497,8 @@ or RebalanceStatus.InsufficientBalance rebalance.Status = RebalanceStatus.Pending; _rebalanceRepository.Update(rebalance); - var delaySeconds = Constants.REBALANCE_INITIAL_RETRY_DELAY_SECONDS - * Math.Pow(Constants.REBALANCE_RETRY_BACKOFF_MULTIPLIER, nextAttempt - 2); + var delaySeconds = Constants.SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS + * Math.Pow(Constants.SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER, nextAttempt - 2); var fireAt = DateTimeOffset.UtcNow.AddSeconds(delaySeconds); var scheduler = await _schedulerFactory.GetScheduler(); diff --git a/src/Shared/NewRebalanceModal.razor b/src/Shared/NewRebalanceModal.razor index 6a50a719..20cfb525 100644 --- a/src/Shared/NewRebalanceModal.razor +++ b/src/Shared/NewRebalanceModal.razor @@ -197,7 +197,7 @@ Min="0.01m" Max="1m"/> - @($"Server default: {Constants.REBALANCE_AMOUNT_BACKOFF_RATIO:0.00}. 1 = always retry the full amount; lower = shrink the amount on each retry.") + @($"Server default: {Constants.SEND_PAYMENT_AMOUNT_BACKOFF_RATIO:0.00}. 1 = always retry the full amount; lower = shrink the amount on each retry.") @@ -213,7 +213,7 @@ Min="1" Max="10"/> - @($"Server default: {Constants.REBALANCE_MAX_ATTEMPTS} attempts.") + @($"Server default: {Constants.SEND_PAYMENT_MAX_ATTEMPTS} attempts.") @@ -293,8 +293,8 @@ private decimal _maxFeePct = Constants.REBALANCE_DEFAULT_MAX_FEE_PCT; private decimal _retryMaxFeePct = Constants.REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT; private int _timeoutSeconds = Constants.DEFAULT_REBALANCE_TIMEOUT_SECONDS; - private decimal _amountBackoffRatio = (decimal)Constants.REBALANCE_AMOUNT_BACKOFF_RATIO; - private int _maxAttempts = Constants.REBALANCE_MAX_ATTEMPTS; + private decimal _amountBackoffRatio = (decimal)Constants.SEND_PAYMENT_AMOUNT_BACKOFF_RATIO; + private int _maxAttempts = Constants.SEND_PAYMENT_MAX_ATTEMPTS; private bool _showAdvanced; private bool _submitting; @@ -414,8 +414,8 @@ _maxFeePct = Constants.REBALANCE_DEFAULT_MAX_FEE_PCT; _retryMaxFeePct = Constants.REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT; _timeoutSeconds = Constants.DEFAULT_REBALANCE_TIMEOUT_SECONDS; - _amountBackoffRatio = (decimal)Constants.REBALANCE_AMOUNT_BACKOFF_RATIO; - _maxAttempts = Constants.REBALANCE_MAX_ATTEMPTS; + _amountBackoffRatio = (decimal)Constants.SEND_PAYMENT_AMOUNT_BACKOFF_RATIO; + _maxAttempts = Constants.SEND_PAYMENT_MAX_ATTEMPTS; _showAdvanced = false; _submitting = false; } diff --git a/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs b/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs index 91ba7140..eedbaba7 100644 --- a/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs +++ b/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs @@ -450,8 +450,8 @@ public async Task ExecuteAsync_InvoiceExpiryCoversFullRetryWindowPlusBuffer() await service.RebalanceAsync(request); long expected = 60 - + Constants.REBALANCE_INITIAL_RETRY_DELAY_SECONDS - + (long)(Constants.REBALANCE_INITIAL_RETRY_DELAY_SECONDS * Constants.REBALANCE_RETRY_BACKOFF_MULTIPLIER) + + Constants.SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS + + (long)(Constants.SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS * Constants.SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER) + 60; capturedExpiry.Should().Be(expected); capturedExpiry.Should().BeGreaterThan(60 + 60, "old TimeoutSeconds+60 expiry was demonstrably too short"); @@ -686,7 +686,7 @@ public async Task ExecuteAsync_AtMaxAttempts_NoFurtherRetryScheduled() NodeId = node.Id, Node = node, Status = RebalanceStatus.Pending, - AttemptNumber = Constants.REBALANCE_MAX_ATTEMPTS, + AttemptNumber = Constants.SEND_PAYMENT_MAX_ATTEMPTS, RequestedAmountSats = 100_000, SatsAmount = 100_000, MaxFeePct = 0.05, @@ -705,7 +705,7 @@ public async Task ExecuteAsync_AtMaxAttempts_NoFurtherRetryScheduled() var result = await service.ExecuteAsync(existing.Id); result.Status.Should().Be(RebalanceStatus.NoRoute); // terminal, no escalation - result.AttemptNumber.Should().Be(Constants.REBALANCE_MAX_ATTEMPTS); + result.AttemptNumber.Should().Be(Constants.SEND_PAYMENT_MAX_ATTEMPTS); _scheduler.Verify(s => s.ScheduleJob(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } From 05d0d2cbb6febf97a2b0bab7c3eda15873368dc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:08:36 +0200 Subject: [PATCH 03/10] Spark swaps retry a failed payment with a smaller amount, like rebalances A payment that fails for no route, a timeout or too little outbound liquidity is retried up to SEND_PAYMENT_MAX_ATTEMPTS attempts, each SEND_PAYMENT_AMOUNT_BACKOFF_RATIO of the previous one and never below the node's Spark min swap. The SSP only issues fixed-amount invoices, so each retry gets a fresh, smaller one, saved on the swap before the delay so the monitor never sees a failed payment. A persistent Quartz job (SparkSwapPaymentJob) pays it after the backoff delay, and only if the node has no record of it yet. The monitor's deadline follows the current invoice. --- docs/spark-swaps.md | 8 +- src/Data/Models/AuditLog.cs | 5 +- src/Data/Models/Swaps.cs | 11 + src/Jobs/SparkSwapPaymentJob.cs | 101 + ...4120616_AddSparkPaymentRetries.Designer.cs | 2021 +++++++++++++++++ .../20261014120616_AddSparkPaymentRetries.cs | 41 + .../ApplicationDbContextModelSnapshot.cs | 6 + src/Services/Spark/SparkSwapService.cs | 176 +- src/Shared/NewSwapModal.razor | 16 +- .../Services/Spark/SparkSwapServiceTests.cs | 185 +- 10 files changed, 2543 insertions(+), 27 deletions(-) create mode 100644 src/Jobs/SparkSwapPaymentJob.cs create mode 100644 src/Migrations/20261014120616_AddSparkPaymentRetries.Designer.cs create mode 100644 src/Migrations/20261014120616_AddSparkPaymentRetries.cs diff --git a/docs/spark-swaps.md b/docs/spark-swaps.md index f6961daf..e3c6684b 100644 --- a/docs/spark-swaps.md +++ b/docs/spark-swaps.md @@ -15,9 +15,13 @@ The Spark client is the [NSpark](https://github.com/orklabs/nspark) .NET SDK, fr 2. A swap that would bring the wallet over its max balance, counting what its swaps not paid yet will still bring in, is refused. 3. The Spark wallet issues an invoice through the SSP. An invoice without a receive request is refused before anything is paid: the swap's funds are found only through the transfer the SSP reports for it. 4. The swap is recorded before anything is paid: its payment hash, destination wallet and the Spark identity that will receive it. No address is reserved yet: the swap needs one only when it exits. -5. The node pays the invoice in the background: creation returns as soon as the swap is recorded, and the swap shows as pending on the Swaps page meanwhile. LND keeps trying routes for up to the node's Spark payment timeout (its liquidity settings on the Nodes page, else `SPARK_PAYMENT_TIMEOUT_MINUTES`), splitting the payment into up to `SEND_PAYMENT_MAX_PARTS` parts. The payment is not cancellable. Once paid, its routing fee is saved; a definitive failure fails the swap. An unknown outcome, such as a broken payment stream or NodeGuard restarting mid-payment, is left to the monitor, which finds the payment by its hash. +5. The node pays the invoice in the background: creation returns as soon as the swap is recorded, and the swap shows as pending on the Swaps page meanwhile. LND keeps trying routes for up to the node's Spark payment timeout (its liquidity settings on the Nodes page, else `SPARK_PAYMENT_TIMEOUT_MINUTES`), splitting the payment into up to `SEND_PAYMENT_MAX_PARTS` parts. The payment is not cancellable. Once paid, its routing fee is saved. An unknown outcome, such as a broken payment stream or NodeGuard restarting mid-payment, is left to the monitor, which finds the payment by its hash. +6. **Retries.** A payment that fails for no route, a timeout or too little outbound liquidity is retried with a smaller amount, like a rebalance: up to `SEND_PAYMENT_MAX_ATTEMPTS` attempts in all, each `SEND_PAYMENT_AMOUNT_BACKOFF_RATIO` of the previous one, never below the node's Spark min swap, after a delay of `SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS` growing by `SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER` (60 s, then 120 s by default). + - A Spark invoice has a fixed amount (the SSP issues none without one), so each retry gets a fresh, smaller invoice. It is saved on the swap before the delay, so the swap never points at a failed payment; the swap's amount is the current attempt's. + - The retry is a Quartz job in the persistent store, so it survives a restart. It pays only if the swap still waits for that invoice and the node has no record of its payment yet. + - Any other failure, or one with no retry left, fails the swap. -**Payment.** `MonitorSwapsJob` runs every 10 minutes, or every minute in a dev environment. It looks the payment up on the node by its hash. The swap fails if the payment failed, or if the node never saw it and the invoice has expired. Once the payment has settled, the swap is `SparkSwapExitJob`'s. +**Payment.** `MonitorSwapsJob` runs every 10 minutes, or every minute in a dev environment. It looks the payment up on the node by its hash. The swap fails if the payment failed, or if the node never saw it and the current invoice expired 10 minutes ago. Once the payment has settled, the swap is `SparkSwapExitJob`'s. **Exit.** `SparkSwapExitJob` runs every `SPARK_EXIT_INTERVAL_MINUTES` (10 by default, 1 in a dev environment). Each paid swap exits on its own, from the Spark wallet it was paid into (found by its identity, whichever wallet the node uses now), to its own destination address, in saved steps: paid, then leaves attributed, then exit sent, then payout confirmed. 1. **Leaves attributed.** A Spark balance is made of leaves of fixed sizes, and the payment arrives as one inbound transfer whose leaves add up to what it brought in. The job claims it and records on the swap the transfer (the one the SSP reports for the swap's receive request), its leaves and their total. A transfer is given to one swap only, and a swap only ever takes its own: while the SSP reports none, the swap waits, and nothing is matched by amount. Only a claimed transfer is attributed: one whose claim failed is claimed again on the next run. A transfer that can never be claimed leaves the swap waiting until the overdue alert. diff --git a/src/Data/Models/AuditLog.cs b/src/Data/Models/AuditLog.cs index 7230ed80..49c904ce 100644 --- a/src/Data/Models/AuditLog.cs +++ b/src/Data/Models/AuditLog.cs @@ -165,7 +165,10 @@ public enum AuditActionType // Spark transit wallet alerts SparkBalanceStuck, - SparkExitOverdue + SparkExitOverdue, + + // A Spark swap's failed payment, retried with a smaller amount + SwapOutPaymentRetryScheduled } /// diff --git a/src/Data/Models/Swaps.cs b/src/Data/Models/Swaps.cs index 26c4c1d6..c1255ee6 100644 --- a/src/Data/Models/Swaps.cs +++ b/src/Data/Models/Swaps.cs @@ -171,6 +171,17 @@ public class SwapOut : Entity /// public string? SparkIdentity { get; set; } + /// + /// Spark swaps only: which attempt the Lightning payment is on. A retry pays a fresh, smaller invoice + /// (, and are the current one's) + /// + public int SparkPaymentAttempt { get; set; } = 1; + + /// + /// Spark swaps only: when the current invoice expires. Its payment can't arrive after that + /// + public DateTimeOffset? SparkInvoiceExpiresAt { get; set; } + /// /// Spark swaps only: the inbound Spark transfer that brought the swap's Lightning payment in. A transfer /// is given to one swap only diff --git a/src/Jobs/SparkSwapPaymentJob.cs b/src/Jobs/SparkSwapPaymentJob.cs new file mode 100644 index 00000000..5235844f --- /dev/null +++ b/src/Jobs/SparkSwapPaymentJob.cs @@ -0,0 +1,101 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using System.Globalization; +using NodeGuard.Services.Spark; +using Quartz; + +namespace NodeGuard.Jobs; + +/// +/// One-shot Quartz job that pays a retry of a Spark swap's payment once its backoff delay is over. Its invoice was +/// issued and saved on the swap when the previous attempt failed; the job carries what paying it needs. Recovered +/// after a restart: never pays twice. +/// +public class SparkSwapPaymentJob : IJob +{ + private const string SwapIdKey = "swapId"; + private const string PaymentHashKey = "paymentHash"; + private const string PaymentRequestKey = "paymentRequest"; + private const string MaxFeePercentKey = "maxFeePercent"; + private const string ChannelsOutKey = "channelsOut"; + private const string TimeoutSecondsKey = "timeoutSeconds"; + + private readonly ILogger _logger; + private readonly ISparkSwapService _sparkSwaps; + + public SparkSwapPaymentJob(ILogger logger, ISparkSwapService sparkSwaps) + { + _logger = logger; + _sparkSwaps = sparkSwaps; + } + + /// Schedules the payment of attempt of a swap's payment at . + public static async Task ScheduleAsync(ISchedulerFactory schedulerFactory, DateTimeOffset fireAt, int swapId, int attempt, + string paymentHash, string paymentRequest, decimal maxFeePercent, ulong[]? channelsOut, int timeoutSeconds) + { + var data = new JobDataMap + { + { SwapIdKey, swapId }, + { PaymentHashKey, paymentHash }, + { PaymentRequestKey, paymentRequest }, + { MaxFeePercentKey, maxFeePercent.ToString(CultureInfo.InvariantCulture) }, + { ChannelsOutKey, string.Join(',', channelsOut ?? []) }, + { TimeoutSecondsKey, timeoutSeconds } + }; + + var job = JobBuilder.Create() + .WithIdentity($"{nameof(SparkSwapPaymentJob)}-{swapId}-a{attempt}") + .UsingJobData(data) + .RequestRecovery() + .Build(); + + var trigger = TriggerBuilder.Create() + .WithIdentity($"{nameof(SparkSwapPaymentJob)}Trigger-{swapId}-a{attempt}") + .StartAt(fireAt) + .Build(); + + var scheduler = await schedulerFactory.GetScheduler(); + await scheduler.ScheduleJob(job, trigger); + } + + public async Task Execute(IJobExecutionContext context) + { + var data = context.MergedJobDataMap; + var swapId = data.GetIntValue(SwapIdKey); + _logger.LogInformation("Paying the scheduled retry of Spark swap {SwapId}", swapId); + + try + { + var channels = data.GetString(ChannelsOutKey); + await _sparkSwaps.PayScheduledAsync(swapId, + data.GetString(PaymentHashKey)!, + data.GetString(PaymentRequestKey)!, + decimal.Parse(data.GetString(MaxFeePercentKey)!, CultureInfo.InvariantCulture), + string.IsNullOrEmpty(channels) ? null : channels.Split(',').Select(ulong.Parse).ToArray(), + data.GetIntValue(TimeoutSecondsKey), + context.CancellationToken); + } + catch (Exception e) + { + _logger.LogError(e, "Error paying the scheduled retry of Spark swap {SwapId}", swapId); + throw new JobExecutionException(e, refireImmediately: false); + } + } +} diff --git a/src/Migrations/20261014120616_AddSparkPaymentRetries.Designer.cs b/src/Migrations/20261014120616_AddSparkPaymentRetries.Designer.cs new file mode 100644 index 00000000..9b1a63c7 --- /dev/null +++ b/src/Migrations/20261014120616_AddSparkPaymentRetries.Designer.cs @@ -0,0 +1,2021 @@ +// +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("20261014120616_AddSparkPaymentRetries")] + partial class AddSparkPaymentRetries + { + /// + 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("FortySwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("FortySwapMinAmountSats") + .HasColumnType("bigint"); + + b.Property("FortySwapWeight") + .HasColumnType("integer"); + + b.Property("FundsDestinationWalletId") + .HasColumnType("integer"); + + b.Property("IsNodeDisabled") + .HasColumnType("boolean"); + + b.Property("LoopSwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("LoopSwapMinAmountSats") + .HasColumnType("bigint"); + + 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("SparkPaymentTimeoutMinutes") + .HasColumnType("integer"); + + b.Property("SparkSwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("SparkSwapMinAmountSats") + .HasColumnType("bigint"); + + b.Property("SparkSwapWeight") + .HasColumnType("integer"); + + b.Property("SparkWalletId") + .HasColumnType("integer"); + + b.Property("SwapBudgetRefreshInterval") + .HasColumnType("interval"); + + b.Property("SwapBudgetSats") + .HasColumnType("bigint"); + + b.Property("SwapBudgetStartDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FundsDestinationWalletId"); + + b.HasIndex("PubKey") + .IsUnique(); + + b.HasIndex("SparkWalletId"); + + 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("DestinationAddress") + .HasColumnType("text"); + + 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("PaymentHash") + .HasColumnType("text"); + + b.Property("PayoutSats") + .HasColumnType("bigint"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("ServiceFeeSats") + .HasColumnType("bigint"); + + b.Property("SparkIdentity") + .HasColumnType("text"); + + b.Property("SparkInvoiceExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SparkLeafIds") + .HasColumnType("text"); + + b.Property("SparkPaymentAttempt") + .HasColumnType("integer"); + + b.Property("SparkReceivedSats") + .HasColumnType("bigint"); + + b.Property("SparkTransferId") + .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.HasKey("Id"); + + b.HasIndex("DestinationWalletId"); + + b.HasIndex("NodeId"); + + b.HasIndex("ReferenceId") + .IsUnique() + .HasFilter("\"ReferenceId\" IS NOT NULL"); + + 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("Kind") + .HasColumnType("integer"); + + b.Property("MofN") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("SparkAccount") + .HasColumnType("integer"); + + b.Property("SparkEncryptedMnemonic") + .HasColumnType("text"); + + b.Property("SparkIdentityPublicKey") + .HasColumnType("text"); + + b.Property("SparkMaxBalanceSats") + .HasColumnType("bigint"); + + 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.HasOne("NodeGuard.Data.Models.Wallet", null) + .WithMany() + .HasForeignKey("SparkWalletId") + .OnDelete(DeleteBehavior.Restrict); + + 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/20261014120616_AddSparkPaymentRetries.cs b/src/Migrations/20261014120616_AddSparkPaymentRetries.cs new file mode 100644 index 00000000..72ca46d5 --- /dev/null +++ b/src/Migrations/20261014120616_AddSparkPaymentRetries.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NodeGuard.Migrations +{ + /// + public partial class AddSparkPaymentRetries : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SparkInvoiceExpiresAt", + table: "SwapOuts", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "SparkPaymentAttempt", + table: "SwapOuts", + type: "integer", + nullable: false, + // Every swap recorded so far is on its first attempt + defaultValue: 1); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SparkInvoiceExpiresAt", + table: "SwapOuts"); + + migrationBuilder.DropColumn( + name: "SparkPaymentAttempt", + table: "SwapOuts"); + } + } +} diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index 79769289..3e167e4d 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1264,9 +1264,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("SparkIdentity") .HasColumnType("text"); + b.Property("SparkInvoiceExpiresAt") + .HasColumnType("timestamp with time zone"); + b.Property("SparkLeafIds") .HasColumnType("text"); + b.Property("SparkPaymentAttempt") + .HasColumnType("integer"); + b.Property("SparkReceivedSats") .HasColumnType("bigint"); diff --git a/src/Services/Spark/SparkSwapService.cs b/src/Services/Spark/SparkSwapService.cs index f08d8d50..cfdae336 100644 --- a/src/Services/Spark/SparkSwapService.cs +++ b/src/Services/Spark/SparkSwapService.cs @@ -23,10 +23,12 @@ using NBXplorer.Models; using NodeGuard.Data.Models; using NodeGuard.Data.Repositories.Interfaces; +using NodeGuard.Jobs; using NSpark; using NSpark.Exceptions; using NSpark.Models; using NSpark.Services; +using Quartz; namespace NodeGuard.Services.Spark; @@ -73,6 +75,14 @@ public interface ISparkSwapService /// Task CreateSwapOutAsync(Node node, SwapOut swapOut, SwapOutRequest request, CancellationToken ct = default); + /// + /// Pays a retry of a swap's payment, scheduled after its previous attempt failed (SparkSwapPaymentJob): only + /// while the swap still waits for that invoice () and the node has no record of + /// its payment yet, so a job run again after a restart never pays twice. + /// + Task PayScheduledAsync(int swapId, string paymentHash, string paymentRequest, decimal maxFeePercent, + ulong[]? channelsOut, int timeoutSeconds, CancellationToken ct = default); + /// /// Follows a pending Spark swap's Lightning payment and returns its state. Once the payment settled, its fee /// is saved on and the swap is SparkSwapExitJob's to exit. @@ -108,8 +118,12 @@ public sealed class SparkSwapService : ISparkSwapService private const string ExitTransferType = "CooperativeExit"; private static readonly TimeSpan PaymentLookupTimeout = TimeSpan.FromSeconds(30); - /// When nothing has arrived by then, the invoice has expired and the payment never will. - private static readonly TimeSpan PaymentDeadline = TimeSpan.FromSeconds(InvoiceExpirySeconds) + TimeSpan.FromMinutes(10); + /// + /// When nothing has arrived by then, the swap's current invoice has expired and its payment never will: 10 + /// minutes after it expires (30 minutes after creation for swaps recorded without its expiry). + /// + private static DateTimeOffset PaymentDeadlineOf(SwapOut swap) => + (swap.SparkInvoiceExpiresAt ?? swap.CreationDatetime + TimeSpan.FromSeconds(InvoiceExpirySeconds)) + TimeSpan.FromMinutes(10); /// Transfers are looked for from this long before the swap was created, for clock skew. private static readonly TimeSpan TransferLookback = TimeSpan.FromMinutes(5); @@ -127,11 +141,15 @@ public sealed class SparkSwapService : ISparkSwapService private readonly ISwapOutRepository _swapOutRepository; private readonly IWalletRepository _walletRepository; private readonly INBXplorerService _nbXplorerService; + private readonly INodeRepository _nodeRepository; + private readonly ISchedulerFactory _schedulerFactory; + private readonly IAuditService _auditService; private readonly TimeProvider _time; public SparkSwapService(ILogger logger, SparkSettings settings, ISparkWalletService spark, ILightningService lightningService, ISwapOutRepository swapOutRepository, IWalletRepository walletRepository, - INBXplorerService nbXplorerService, TimeProvider time) + INBXplorerService nbXplorerService, INodeRepository nodeRepository, ISchedulerFactory schedulerFactory, + IAuditService auditService, TimeProvider time) { _logger = logger; _settings = settings; @@ -140,6 +158,9 @@ public SparkSwapService(ILogger logger, SparkSettings settings _swapOutRepository = swapOutRepository; _walletRepository = walletRepository; _nbXplorerService = nbXplorerService; + _nodeRepository = nodeRepository; + _schedulerFactory = schedulerFactory; + _auditService = auditService; _time = time; } @@ -214,6 +235,8 @@ public async Task CreateSwapOutAsync(Node node, SwapOut swapOut swapOut.Status = SwapOutStatus.Pending; swapOut.SatsAmount = request.Amount; swapOut.PaymentHash = invoice.PaymentHash; + swapOut.SparkPaymentAttempt = 1; + swapOut.SparkInvoiceExpiresAt = invoice.ExpiresAt; swapOut.SparkIdentity = status.IdentityPublicKey; // Recorded before paying: from here on the monitor can resolve the swap whatever happens next @@ -231,8 +254,7 @@ public async Task CreateSwapOutAsync(Node node, SwapOut swapOut // Paid in the background, for up to the node's payment timeout: the swap shows as pending meanwhile. Off // the caller's synchronization context, so the payment doesn't depend on the page that created the swap var maxFeePercent = request.MaxRoutingFeesPercent ?? DefaultMaxRoutingFeePercent; - var feeLimitMsat = (long)(request.Amount * 1000m * maxFeePercent / 100m); - _ = Task.Run(() => PayAsync(node, swapOut.Id, paymentRequest, feeLimitMsat, request.ChannelsOut, + _ = Task.Run(() => PayAsync(node, swapOut.Id, paymentRequest, request.Amount, maxFeePercent, request.ChannelsOut, _settings.PaymentTimeoutFor(node) * 60)); _logger.LogInformation("Spark swap {SwapId}: {Node} is paying {Amount} sats into Spark", swapOut.Id, node.Name, request.Amount); @@ -240,18 +262,20 @@ public async Task CreateSwapOutAsync(Node node, SwapOut swapOut } /// - /// Pays a recorded swap's invoice and saves the outcome: the routing fee once paid, the failure once it - /// definitely failed. An unknown outcome, or an error here, is left to MonitorSwapsJob, which finds the payment - /// by its hash. Not cancellable: once the payment starts it is followed to its end. + /// Pays a recorded swap's invoice of , with a fee limit of + /// of it, and saves the outcome: the routing fee once paid; once it failed for + /// want of a route or liquidity, a smaller retry (), else the failure. An unknown + /// outcome, or an error here, is left to MonitorSwapsJob, which finds the payment by its hash. Not cancellable: + /// once the payment starts it is followed to its end. /// - internal async Task PayAsync(Node node, int swapId, string paymentRequest, long feeLimitMsat, ulong[]? channelsOut, - int timeoutSeconds) + internal async Task PayAsync(Node node, int swapId, string paymentRequest, long amountSats, decimal maxFeePercent, + ulong[]? channelsOut, int timeoutSeconds) { try { - var result = await _lightningService.SendPaymentV2Async(node, paymentRequest, 0, feeLimitMsat, channelsOut, null, - timeoutSeconds, CancellationToken.None); - await SavePaymentAsync(node, swapId, result); + var result = await _lightningService.SendPaymentV2Async(node, paymentRequest, 0, + FeeLimitMsat(amountSats, maxFeePercent), channelsOut, null, timeoutSeconds, CancellationToken.None); + await SavePaymentAsync(node, swapId, result, maxFeePercent, channelsOut, timeoutSeconds); } catch (Exception e) { @@ -259,7 +283,10 @@ internal async Task PayAsync(Node node, int swapId, string paymentRequest, long } } - private async Task SavePaymentAsync(Node node, int swapId, Payment result) + private static long FeeLimitMsat(long amountSats, decimal maxFeePercent) => (long)(amountSats * 1000m * maxFeePercent / 100m); + + private async Task SavePaymentAsync(Node node, int swapId, Payment result, decimal maxFeePercent, ulong[]? channelsOut, + int timeoutSeconds) { // Read again once the payment ended: the monitor may have resolved the swap meanwhile if (await _swapOutRepository.GetById(swapId) is not { Status: SwapOutStatus.Pending, LightningFeeSats: null } swap) @@ -278,10 +305,18 @@ private async Task SavePaymentAsync(Node node, int swapId, Payment result) else if (result.Status == Payment.Types.PaymentStatus.Failed && result.FailureReason != PaymentFailureReason.FailureReasonError) { + if (RetriedFailures.Contains(result.FailureReason) && + await RetrySmallerAsync(node, swap, result.FailureReason, maxFeePercent, channelsOut, timeoutSeconds)) + { + return; + } + swap.Status = SwapOutStatus.Failed; - swap.ErrorDetails = $"Lightning payment failed: {result.FailureReason}"; - _logger.LogWarning("Spark swap {SwapId}: the payment from {Node} failed: {Reason}", - swap.Id, node.Name, result.FailureReason); + swap.ErrorDetails = swap.SparkPaymentAttempt > 1 + ? $"Lightning payment failed: {result.FailureReason} (attempt {swap.SparkPaymentAttempt}, {swap.SatsAmount} sats)" + : $"Lightning payment failed: {result.FailureReason}"; + _logger.LogWarning("Spark swap {SwapId}: the payment from {Node} failed on attempt {Attempt}: {Reason}", + swap.Id, node.Name, swap.SparkPaymentAttempt, result.FailureReason); } else { @@ -294,6 +329,111 @@ private async Task SavePaymentAsync(Node node, int swapId, Payment result) Save(swap); } + /// Failures a smaller amount may get past: no route, a timeout, or too little outbound liquidity. + private static readonly HashSet RetriedFailures = + [ + PaymentFailureReason.FailureReasonNoRoute, + PaymentFailureReason.FailureReasonTimeout, + PaymentFailureReason.FailureReasonInsufficientBalance + ]; + + /// + /// Retries a failed payment with a smaller amount (SEND_PAYMENT_AMOUNT_BACKOFF_RATIO, down to the node's Spark + /// min swap, up to SEND_PAYMENT_MAX_ATTEMPTS attempts): issues a fresh invoice for it right away and saves it on + /// the swap, so the swap never points at a failed payment and the monitor leaves it be, then schedules its + /// payment after the backoff delay (SparkSwapPaymentJob). False when no retry is left, or the new invoice could + /// not be issued or saved. + /// + private async Task RetrySmallerAsync(Node node, SwapOut swap, PaymentFailureReason reason, decimal maxFeePercent, + ulong[]? channelsOut, int timeoutSeconds) + { + var attempt = swap.SparkPaymentAttempt + 1; + var amount = (long)Math.Floor(swap.SatsAmount * (decimal)Constants.SEND_PAYMENT_AMOUNT_BACKOFF_RATIO); + var minSwap = SwapAmountLimits.For(node, SwapProvider.Spark, _settings).MinSats; + if (attempt > Constants.SEND_PAYMENT_MAX_ATTEMPTS || amount < minSwap || amount >= swap.SatsAmount) + { + _logger.LogInformation("Spark swap {SwapId}: no retry left after attempt {Attempt} ({Amount} sats; next {Next}, min {Min})", + swap.Id, swap.SparkPaymentAttempt, swap.SatsAmount, amount, minSwap); + return false; + } + + LightningInvoice invoice; + try + { + var wallet = await SwapWalletAsync(swap, CancellationToken.None); + invoice = await _spark.CreateInvoiceAsync(wallet.Ref, amount, $"NodeGuard swap-out from {node.Name}", + InvoiceExpirySeconds, CancellationToken.None); + } + catch (Exception e) + { + _logger.LogWarning(e, "Spark swap {SwapId}: could not issue the invoice for its retry", swap.Id); + return false; + } + + if (string.IsNullOrEmpty(invoice.RequestId)) + { + _logger.LogWarning("Spark swap {SwapId}: the SSP issued the retry's invoice without a receive request", swap.Id); + return false; + } + + var previous = (swap.ProviderId, swap.PaymentHash, swap.SatsAmount, swap.SparkPaymentAttempt, swap.SparkInvoiceExpiresAt); + swap.ProviderId = invoice.RequestId; + swap.PaymentHash = invoice.PaymentHash; + swap.SatsAmount = amount; + swap.SparkPaymentAttempt = attempt; + swap.SparkInvoiceExpiresAt = invoice.ExpiresAt; + if (!Save(swap)) + { + (swap.ProviderId, swap.PaymentHash, swap.SatsAmount, swap.SparkPaymentAttempt, swap.SparkInvoiceExpiresAt) = previous; + return false; + } + + var delaySeconds = Constants.SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS * + Math.Pow(Constants.SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER, attempt - 2); + var fireAt = _time.GetUtcNow().AddSeconds(delaySeconds); + await SparkSwapPaymentJob.ScheduleAsync(_schedulerFactory, fireAt, swap.Id, attempt, invoice.PaymentHash, + invoice.PaymentRequest, maxFeePercent, channelsOut, timeoutSeconds); + + _logger.LogInformation("Spark swap {SwapId}: the payment from {Node} failed ({Reason}); attempt {Attempt} pays {Amount} sats at {FireAt:O}", + swap.Id, node.Name, reason, attempt, amount, fireAt); + await _auditService.LogSystemAsync(AuditActionType.SwapOutPaymentRetryScheduled, AuditEventType.Attempt, + AuditObjectType.SwapOut, swap.Id.ToString(), + new { Reason = reason.ToString(), Attempt = attempt, AmountSats = amount, previous.SatsAmount, FireAt = fireAt }); + return true; + } + + public async Task PayScheduledAsync(int swapId, string paymentHash, string paymentRequest, decimal maxFeePercent, + ulong[]? channelsOut, int timeoutSeconds, CancellationToken ct = default) + { + if (await _swapOutRepository.GetById(swapId) is not { Status: SwapOutStatus.Pending, LightningFeeSats: null } swap || + swap.PaymentHash != paymentHash) + { + _logger.LogInformation("Spark swap {SwapId}: no longer waits for the payment of {PaymentHash}, which is not sent", + swapId, paymentHash); + return; + } + + if (swap.NodeId is not { } nodeId || await _nodeRepository.GetById(nodeId) is not { } node) + { + _logger.LogError("Spark swap {SwapId}: its node {NodeId} is gone, so its retry is not paid", swapId, swap.NodeId); + return; + } + + // Run again after a restart: what the node already knows of is followed by the monitor instead + using (var lookup = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + lookup.CancelAfter(PaymentLookupTimeout); + if (await _lightningService.TrackPaymentV2Async(node, Convert.FromHexString(paymentHash), lookup.Token) is { } known) + { + _logger.LogInformation("Spark swap {SwapId}: the node already has the payment of attempt {Attempt} ({Status})", + swapId, swap.SparkPaymentAttempt, known.Status); + return; + } + } + + await PayAsync(node, swapId, paymentRequest, swap.SatsAmount, maxFeePercent, channelsOut, timeoutSeconds); + } + public async Task AdvanceSwapAsync(Node node, SwapOut swap, CancellationToken ct = default) { if (string.IsNullOrEmpty(swap.PaymentHash)) @@ -448,7 +588,7 @@ public async Task ExitAsync(SwapOut swap, CancellationToken ct = case Payment.Types.PaymentStatus.Failed: return $"Lightning payment failed: {payment.FailureReason}"; - case null when _time.GetUtcNow() - swap.CreationDatetime > PaymentDeadline: + case null when _time.GetUtcNow() > PaymentDeadlineOf(swap): // The node has no record of the payment and the invoice expired: no payment will arrive. The SSP's // view is only a hint (not every SSP serves it), so a failed lookup does not block this var receive = await ReceiveStatusAsync(swap, sparkWallet); diff --git a/src/Shared/NewSwapModal.razor b/src/Shared/NewSwapModal.razor index 3e73e695..39581d44 100644 --- a/src/Shared/NewSwapModal.razor +++ b/src/Shared/NewSwapModal.razor @@ -535,7 +535,9 @@ "
" + $"The node starts paying its Spark wallet ({SparkIdentityOf(_selectedNode!)}) as you confirm, and LND keeps " + $"trying routes for up to {SparkSettings.PaymentTimeoutFor(_selectedNode!)} minutes (the node's Spark payment timeout); " + - "the swap shows as pending on the Swaps page meanwhile. Once paid, the swap exits " + + "the swap shows as pending on the Swaps page meanwhile. " + + SparkRetrySummary(_selectedNode!) + + "Once paid, the swap exits " + $"on-chain to the destination wallet on its own, within about {SparkSettings.ExitIntervalOrDefault(Constants.IS_DEV_ENVIRONMENT)} minutes, " + $"at exit speed {SparkSettings.ExitSpeedSummary}. " + $"Up to {maxSwapsInFlight} Spark swaps of {_selectedNode?.Name} (its Max swaps in flight) run at once.

" + @@ -625,6 +627,18 @@ private string? SparkIdentityOf(Node node) => SparkSettings.WalletFor(node) is { } wallet ? SparkWallet.GetStatus(wallet).IdentityPublicKey : null; + /// How a Spark swap's failed payment is retried, as the confirmation says it + private string SparkRetrySummary(Node node) + { + var retries = Constants.SEND_PAYMENT_MAX_ATTEMPTS - 1; + if (retries < 1) return ""; + + var smallerPercent = Math.Round((1 - (decimal)Constants.SEND_PAYMENT_AMOUNT_BACKOFF_RATIO) * 100); + var minSwap = new Money(SwapAmountLimits.For(node, SwapProvider.Spark, SparkSettings).MinSats); + return $"If no route or liquidity can carry it, the payment is retried up to {retries} times, each {smallerPercent}% " + + $"smaller, down to the node's Spark min swap ({minSwap.ToUnit(MoneyUnit.BTC)} BTC). "; + } + private IEnumerable AvailableProviders => Enum.GetValues().Where(p => p != SwapProvider.Spark || SparkSettings.Enabled); diff --git a/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs index a207b34f..608fdbc2 100644 --- a/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs +++ b/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs @@ -31,6 +31,7 @@ using NSpark.Services; using NSubstitute; using NSubstitute.ExceptionExtensions; +using Quartz; namespace NodeGuard.Services.Spark; @@ -54,6 +55,10 @@ public class SparkSwapServiceTests private readonly ISwapOutRepository _swapOuts = Substitute.For(); private readonly IWalletRepository _wallets = Substitute.For(); private readonly INBXplorerService _nbXplorer = Substitute.For(); + private readonly INodeRepository _nodes = Substitute.For(); + private readonly ISchedulerFactory _schedulerFactory = Substitute.For(); + private readonly IScheduler _scheduler = Substitute.For(); + private readonly IAuditService _audit = Substitute.For(); private readonly ManualTime _time = new(); private readonly Node _node = new() { Id = 1, Name = "alice", PubKey = "02aa", Endpoint = "alice:10009", SparkWalletId = 5 }; private readonly Wallet _wallet; @@ -79,12 +84,23 @@ public SparkSwapServiceTests() _wallet.Id = 3; _wallets.GetById(3).Returns(_wallet); Payouts(); + _nodes.GetById(1, Arg.Any()).Returns(_node); + _schedulerFactory.GetScheduler(Arg.Any()).Returns(_scheduler); } private SparkSwapService Service() => new(NullLogger.Instance, Settings, _spark, _lightning, _swapOuts, - _wallets, _nbXplorer, _time); + _wallets, _nbXplorer, _nodes, _schedulerFactory, _audit, _time); - private Task Pay(int swapId = 7) => Service().PayAsync(_node, swapId, Invoice, 5_000_000, null, 600); + // 500_000 sats at 1%: a fee limit of 5_000_000 msat + private Task Pay(int swapId = 7) => Service().PayAsync(_node, swapId, Invoice, 500_000, 1m, null, 600); + + private const string RetryInvoice = "lnbcrt4m1retry"; + private const string RetryHash = "0202020202020202020202020202020202020202020202020202020202020202"; + + /// The SSP issues the retry's smaller invoice. + private void RetryInvoiceIssued(long amountSats) => + _spark.CreateInvoiceAsync(W, amountSats, Arg.Any(), SparkSwapService.InvoiceExpirySeconds, Arg.Any()) + .Returns(new LightningInvoice(RetryInvoice, RetryHash, amountSats, DateTimeOffset.UnixEpoch.AddMinutes(90), "req-2")); /// /// Completes with the payment's timeout once the node starts paying in the background. The payment itself never @@ -276,17 +292,161 @@ public async Task Pay_ASucceededPayment_SavesItsRoutingFee_AndIsNotCancellable() } [Fact] - public async Task Pay_WhenThePaymentFails_FailsTheSwap() + public async Task Pay_WhenThePaymentFailsForAReasonASmallerAmountWontFix_FailsTheSwap() { var swap = Pending(lightningFee: null); _swapOuts.GetById(7).Returns(swap); - NodePays(Payment.Types.PaymentStatus.Failed, PaymentFailureReason.FailureReasonNoRoute); + NodePays(Payment.Types.PaymentStatus.Failed, PaymentFailureReason.FailureReasonIncorrectPaymentDetails); await Pay(); swap.Status.Should().Be(SwapOutStatus.Failed); - swap.ErrorDetails.Should().Contain("FailureReasonNoRoute"); + swap.ErrorDetails.Should().Contain("FailureReasonIncorrectPaymentDetails"); _swapOuts.Received(1).Update(swap); + await _spark.DidNotReceiveWithAnyArgs().CreateInvoiceAsync(default, default, default, default, default); + } + + [Theory] + [InlineData(PaymentFailureReason.FailureReasonNoRoute)] + [InlineData(PaymentFailureReason.FailureReasonTimeout)] + [InlineData(PaymentFailureReason.FailureReasonInsufficientBalance)] + public async Task Pay_WhenAFailureASmallerAmountMayFix_SavesASmallerInvoice_ThenSchedulesItsPayment(PaymentFailureReason reason) + { + var swap = Pending(lightningFee: null); + _swapOuts.GetById(7).Returns(swap); + NodePays(Payment.Types.PaymentStatus.Failed, reason); + RetryInvoiceIssued(400_000); + + await Pay(); + + swap.Status.Should().Be(SwapOutStatus.Pending); + swap.Should().BeEquivalentTo(new + { + SatsAmount = 400_000L, PaymentHash = RetryHash, ProviderId = "req-2", SparkPaymentAttempt = 2, + SparkInvoiceExpiresAt = (DateTimeOffset?)DateTimeOffset.UnixEpoch.AddMinutes(90) + }, "the swap never points at the failed payment, so the monitor leaves it be"); + NSubstitute.Received.InOrder(() => + { + _swapOuts.Update(swap); + _scheduler.ScheduleJob( + Arg.Is(j => j.Key.Name == "SparkSwapPaymentJob-7-a2" && j.RequestsRecovery && + j.JobDataMap.GetString("paymentHash") == RetryHash && + j.JobDataMap.GetString("paymentRequest") == RetryInvoice), + Arg.Is(t => t.StartTimeUtc == _time.GetUtcNow().AddSeconds(60)), Arg.Any()); + }); + await _audit.Received(1).LogSystemAsync(AuditActionType.SwapOutPaymentRetryScheduled, AuditEventType.Attempt, + AuditObjectType.SwapOut, "7", Arg.Any()); + } + + [Fact] + public async Task Pay_EachRetryWaitsLonger() + { + var swap = Pending(lightningFee: null); + swap.SparkPaymentAttempt = 2; + swap.SatsAmount = 400_000; + _swapOuts.GetById(7).Returns(swap); + NodePays(Payment.Types.PaymentStatus.Failed, PaymentFailureReason.FailureReasonNoRoute); + RetryInvoiceIssued(320_000); + + await Pay(); + + swap.SparkPaymentAttempt.Should().Be(3); + await _scheduler.Received(1).ScheduleJob(Arg.Is(j => j.Key.Name == "SparkSwapPaymentJob-7-a3"), + Arg.Is(t => t.StartTimeUtc == _time.GetUtcNow().AddSeconds(120)), Arg.Any()); + } + + [Fact] + public async Task Pay_OnTheLastAttempt_FailsTheSwap() + { + var swap = Pending(lightningFee: null); + swap.SparkPaymentAttempt = Constants.SEND_PAYMENT_MAX_ATTEMPTS; + _swapOuts.GetById(7).Returns(swap); + NodePays(Payment.Types.PaymentStatus.Failed, PaymentFailureReason.FailureReasonNoRoute); + + await Pay(); + + swap.Status.Should().Be(SwapOutStatus.Failed); + swap.ErrorDetails.Should().Contain("FailureReasonNoRoute").And.Contain($"attempt {Constants.SEND_PAYMENT_MAX_ATTEMPTS}"); + await _spark.DidNotReceiveWithAnyArgs().CreateInvoiceAsync(default, default, default, default, default); + await _scheduler.DidNotReceiveWithAnyArgs().ScheduleJob(default!, default!, default); + } + + [Fact] + public async Task Pay_WhenTheSmallerAmountIsBelowTheNodesSparkMinSwap_FailsTheSwap() + { + _node.SparkSwapMinAmountSats = 450_000; + var swap = Pending(lightningFee: null); + _swapOuts.GetById(7).Returns(swap); + NodePays(Payment.Types.PaymentStatus.Failed, PaymentFailureReason.FailureReasonNoRoute); + + await Pay(); + + swap.Status.Should().Be(SwapOutStatus.Failed); + await _spark.DidNotReceiveWithAnyArgs().CreateInvoiceAsync(default, default, default, default, default); + } + + [Fact] + public async Task Pay_WhenTheRetrysInvoiceCannotBeIssued_FailsTheSwap() + { + var swap = Pending(lightningFee: null); + _swapOuts.GetById(7).Returns(swap); + NodePays(Payment.Types.PaymentStatus.Failed, PaymentFailureReason.FailureReasonNoRoute); + _spark.CreateInvoiceAsync(W, 400_000, Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new SparkConnectionException("request_lightning_receive", "the SSP is down")); + + await Pay(); + + swap.Status.Should().Be(SwapOutStatus.Failed); + swap.SatsAmount.Should().Be(500_000); + await _scheduler.DidNotReceiveWithAnyArgs().ScheduleJob(default!, default!, default); + } + + // ── Paying a scheduled retry ────────────────────────────────────────────────────────── + + private Task PayScheduled(string paymentHash = RetryHash) => + Service().PayScheduledAsync(7, paymentHash, RetryInvoice, 1m, null, 600); + + private SwapOut RetryPending() + { + var swap = Pending(lightningFee: null); + swap.SatsAmount = 400_000; + swap.PaymentHash = RetryHash; + swap.SparkPaymentAttempt = 2; + _swapOuts.GetById(7).Returns(swap); + return swap; + } + + [Fact] + public async Task PayScheduled_PaysTheRetrysInvoice_WithItsFeeLimit() + { + RetryPending(); + _lightning.TrackPaymentV2Async(_node, Arg.Any(), Arg.Any()).Returns((Payment?)null); + + await PayScheduled(); + + await _lightning.Received(1).SendPaymentV2Async(_node, RetryInvoice, 0, 4_000_000, null, null, 600, CancellationToken.None); + } + + [Fact] + public async Task PayScheduled_ASwapNoLongerWaitingForThatInvoice_IsNotPaid() + { + RetryPending(); + + await PayScheduled(paymentHash: Hash); + + await _lightning.DidNotReceiveWithAnyArgs().SendPaymentV2Async(default!, default!, default, default, default, default, default, default); + } + + [Fact] + public async Task PayScheduled_WhenTheNodeAlreadyHasItsPayment_LeavesItToTheMonitor() + { + RetryPending(); + _lightning.TrackPaymentV2Async(_node, Arg.Any(), Arg.Any()) + .Returns(new Payment { Status = Payment.Types.PaymentStatus.InFlight }); + + await PayScheduled(); + + await _lightning.DidNotReceiveWithAnyArgs().SendPaymentV2Async(default!, default!, default, default, default, default, default, default); } [Fact] @@ -503,6 +663,21 @@ public async Task Advance_AnUnknownPayment_WaitsUntilTheInvoiceExpired_ThenFails response.ErrorMessage.Should().Contain("never reached the node").And.Contain("unknown"); } + [Fact] + public async Task Advance_AnUnknownPayment_WaitsForTheCurrentInvoice_NotTheFirstOne() + { + NodeReports(null); + var swap = Pending(lightningFee: null); + swap.SparkPaymentAttempt = 2; + swap.SparkInvoiceExpiresAt = DateTimeOffset.UnixEpoch.AddMinutes(90); + + _time.Set(DateTimeOffset.UnixEpoch.AddMinutes(95)); + (await Service().AdvanceSwapAsync(_node, swap)).Status.Should().Be(SwapOutStatus.Pending); + + _time.Set(DateTimeOffset.UnixEpoch.AddMinutes(101)); + (await Service().AdvanceSwapAsync(_node, swap)).Status.Should().Be(SwapOutStatus.Failed); + } + [Fact] public async Task Advance_ASwapPaidIntoAnotherSparkIdentity_IsRefused() { From d691abe690ced9c4b63a2c8c7f17935eb3920548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:22:07 +0200 Subject: [PATCH 04/10] Spark swaps wait in their wallet until it is full, then exit together A cooperative exit's fee doesn't grow with its leaves, so paid swaps now wait in their Spark wallet and exit in one batch once it is full: the room left under its max balance is below the smallest Spark min swap of the nodes using it. One exit per destination wallet, of at most 100 leaves (open-ssp's limit), to one reserved address saved on every swap first; the on-chain fee is shared in proportion to what each swap brought in. A swap whose leaves can't move leaves the batch. Max swaps in flight counts a Spark swap only until it is paid, and the overdue alert counts from the first exit attempt. --- docs/spark-swaps.md | 27 +- src/Data/Models/Swaps.cs | 6 + .../Interfaces/ISwapOutRepository.cs | 6 +- src/Data/Repositories/SwapOutRepository.cs | 24 +- src/Jobs/AutoLiquidityManagementJob.cs | 3 +- src/Jobs/SparkSwapExitJob.cs | 54 +- ...1014121804_AddSparkExitBatches.Designer.cs | 2024 +++++++++++++++++ .../20261014121804_AddSparkExitBatches.cs | 29 + .../ApplicationDbContextModelSnapshot.cs | 3 + src/Services/Spark/SparkGuardrails.cs | 27 +- src/Services/Spark/SparkSettings.cs | 2 +- src/Services/Spark/SparkSwapService.cs | 287 ++- src/Services/Spark/SparkWalletService.cs | 16 +- src/Shared/NewSwapModal.razor | 6 +- src/Shared/SparkWalletModals.razor | 2 +- .../E2E/SparkSwapOutE2ETests.cs | 63 +- .../Jobs/SparkSwapExitJobTests.cs | 76 +- .../Services/Spark/SparkGuardrailsTests.cs | 28 + .../Services/Spark/SparkSwapServiceTests.cs | 219 +- .../Spark/SparkWalletManagementTests.cs | 25 +- 20 files changed, 2744 insertions(+), 183 deletions(-) create mode 100644 src/Migrations/20261014121804_AddSparkExitBatches.Designer.cs create mode 100644 src/Migrations/20261014121804_AddSparkExitBatches.cs diff --git a/docs/spark-swaps.md b/docs/spark-swaps.md index e3c6684b..a4945c1a 100644 --- a/docs/spark-swaps.md +++ b/docs/spark-swaps.md @@ -2,16 +2,16 @@ Spark is a swap-out provider next to Loop and 40swap. A node moves Lightning liquidity to a NodeGuard on-chain wallet through a [Spark](https://www.spark.money/) wallet of NodeGuard's: 1. The node pays an invoice of the Spark wallet. -2. What that payment brought in exits on-chain to an address of the destination wallet, reserved when the swap exits, in a cooperative exit brokered by the Spark Service Provider (SSP). +2. What that payment brought in waits in the Spark wallet until the wallet is full, then exits on-chain with the wallet's other paid swaps, to an address of the destination wallet reserved at that moment, in one cooperative exit brokered by the Spark Service Provider (SSP). -NodeGuard is not a Spark wallet product. A Spark wallet is transit only: each swap's sats leave in that swap's own exit, and the guardrails below alert when sats stay behind. +NodeGuard is not a Spark wallet product. A Spark wallet is transit only: each swap's sats leave in an exit of their own leaves, and the guardrails below alert when sats stay behind. The Spark client is the [NSpark](https://github.com/orklabs/nspark) .NET SDK, from the [Elenpay fork](https://github.com/Elenpay/nspark) in `vendor/nspark` (a git submodule). By default, on mainnet, the operators are Lightspark, Breez and Flashnet (2-of-3) and the SSP is Lightspark's. ## How a swap runs **Creation.** Swaps are created by auto-liquidity, the New Swap dialog, or the `RequestSwapOut` RPC. A swap goes through the node's Spark wallet (see [Signing](#signing)). -1. A node may have up to its **Max swaps in flight** Spark swaps open at once (the node setting on the Nodes page; 0 counts as 1). An in-process lock serializes the check and the record, so concurrent requests can't go over it. +1. A node may have up to its **Max swaps in flight** Spark swaps being paid at once (the node setting on the Nodes page; 0 counts as 1). Once paid, a swap waits in the wallet, whose max balance limits it, and no longer counts. An in-process lock serializes the check and the record, so concurrent requests can't go over it. 2. A swap that would bring the wallet over its max balance, counting what its swaps not paid yet will still bring in, is refused. 3. The Spark wallet issues an invoice through the SSP. An invoice without a receive request is refused before anything is paid: the swap's funds are found only through the transfer the SSP reports for it. 4. The swap is recorded before anything is paid: its payment hash, destination wallet and the Spark identity that will receive it. No address is reserved yet: the swap needs one only when it exits. @@ -23,20 +23,21 @@ The Spark client is the [NSpark](https://github.com/orklabs/nspark) .NET SDK, fr **Payment.** `MonitorSwapsJob` runs every 10 minutes, or every minute in a dev environment. It looks the payment up on the node by its hash. The swap fails if the payment failed, or if the node never saw it and the current invoice expired 10 minutes ago. Once the payment has settled, the swap is `SparkSwapExitJob`'s. -**Exit.** `SparkSwapExitJob` runs every `SPARK_EXIT_INTERVAL_MINUTES` (10 by default, 1 in a dev environment). Each paid swap exits on its own, from the Spark wallet it was paid into (found by its identity, whichever wallet the node uses now), to its own destination address, in saved steps: paid, then leaves attributed, then exit sent, then payout confirmed. +**Exit.** `SparkSwapExitJob` runs every `SPARK_EXIT_INTERVAL_MINUTES` (10 by default, 1 in a dev environment). Paid swaps exit from the Spark wallet they were paid into (found by its identity, whichever wallet the node uses now), in saved steps: paid, then leaves attributed, then exit sent, then payout confirmed. 1. **Leaves attributed.** A Spark balance is made of leaves of fixed sizes, and the payment arrives as one inbound transfer whose leaves add up to what it brought in. The job claims it and records on the swap the transfer (the one the SSP reports for the swap's receive request), its leaves and their total. A transfer is given to one swap only, and a swap only ever takes its own: while the SSP reports none, the swap waits, and nothing is matched by amount. Only a claimed transfer is attributed: one whose claim failed is claimed again on the next run. A transfer that can never be claimed leaves the swap waiting until the overdue alert. -2. **Exit sent.** The job reserves the swap's address in the destination wallet and saves it on the swap, then exactly those leaves exit to it. No leaf is split or swapped with the SSP, so the remote signer can sign the exit too. The exit fee is capped by `SPARK_MAX_EXIT_FEE_SATS`, and the exit's txid is saved right away. +2. **Waiting for the wallet to fill.** A cooperative exit's fee doesn't grow with its leaves (Lightspark charges per exit; open-ssp's exit transaction always has the same shape), so paid swaps wait in their Spark wallet and exit together. The wallet is **full** once the room left under its max balance (counting what its swaps not paid yet will bring in) is below the smallest Spark min swap of the nodes using it, so no node can add a swap; a wallet no node uses counts as full. A full wallet's swaps exit once none of its swaps is still being paid. There is no max wait: to exit a wallet's swaps early, lower its max balance. +3. **Exit sent.** The full wallet's attributed swaps exit in batches, one per destination wallet (an exit pays one address), oldest swaps first, of at most 100 leaves (open-ssp's limit; a swap's leaves are never split across exits). The job reserves one address in the destination wallet and saves it on every swap of the batch, then exactly their leaves exit to it. No leaf is split or swapped with the SSP, so the remote signer can sign the exit too. The exit fee is capped by `SPARK_MAX_EXIT_FEE_SATS`, and the exit's txid is saved on every swap right away. - The exit goes at `SPARK_EXIT_SPEED`, `SLOW` by default: the cheapest, at the cost of time. The SSP quotes each speed separately, a user fee plus an L1 broadcast fee at the fee rate that speed aims for: `250 × sats_per_vbyte + 750` in all ([Spark fees](https://docs.spark.money/wallets/estimate-fees), [Withdraw to L1](https://docs.spark.money/wallets/withdraw-to-l1)). Spark doesn't promise a confirmation time for any speed; under high fees a `SLOW` exit can take hours. - - Leaves near their timelock floor are renewed first. A renewal keeps a leaf's id (the operators renew it in place), so the swap still exits its own leaves. If they can't move yet (a renewal failed, or they are frozen), the swap waits and tries again on the next run, and the overdue alert below flags it. No other leaves are ever taken in their place. - - If the leaves already exited (a crash before the txid was saved), the job waits for the payout instead of exiting again. -3. **Payout confirmed.** The exit's leaves stay the wallet's (owned, locked) until its transaction confirms; the swap stays pending meanwhile and still counts against the node's Max swaps in flight. The swap completes when the destination wallet sees a confirmed payout to the reserved address. Matching on the address, rather than the exit's txid, survives the SSP fee-bumping the exit. The payout is checked before exiting, so an exit whose txid was not saved never runs twice. + - Leaves near their timelock floor are renewed first. A renewal keeps a leaf's id (the operators renew it in place), so each swap still exits its own leaves. A swap whose leaves can't move yet (a renewal failed, or they are frozen) leaves the batch and waits for the next one; the others exit without it, and the overdue alert below flags it. No other leaves are ever taken in its place. + - If the leaves already exited (a crash before the txid was saved), the job waits for the payout instead of exiting again. The swaps sharing a reserved address are the batch from then on. +4. **Payout confirmed.** The exit's leaves stay the wallet's (owned, locked) until its transaction confirms; the swaps stay pending meanwhile. They complete together when the destination wallet sees a confirmed payout to the reserved address. Matching on the address, rather than the exit's txid, survives the SSP fee-bumping the exit. The payout is checked before exiting, so an exit whose txid was not saved never runs twice. -All attributions run before any exit, and exits go strictly one at a time, oldest swap first. This is separate from Max swaps in flight: that limit decides how many swaps a node may have open; the job only orders their exits. With Max swaps in flight at 3, three swaps are paid in parallel, and the next run exits the first, then the second, then the third. +All attributions run before any exit, and exits go strictly one at a time, oldest batch first. **Fees and accounting.** What the swap delivered is what landed on-chain: its payout, shown as "Received on-chain" on the Swaps page. The fees add up to the amount minus the payout, plus the routing fee: - **Routing fee:** what the node paid on Lightning. It includes Spark's fixed 15 bps (0.15%), which Spark charges to the sender on the Lightning route through the invoice's route hints ([Spark fees](https://docs.spark.money/wallets/estimate-fees)); receiving into Spark is free. - **Service fee:** shown as Spark's 15 bps "in routing fee", not added to the total. Only if the payment brought in less than the amount is that shortfall recorded as a service fee. -- **On-chain fee:** the move from Spark to L1: the SSP's exit fee and the L1 broadcast at the exit speed, what the swap's leaves held minus the payout. +- **On-chain fee:** the move from Spark to L1: the SSP's exit fee and the L1 broadcast at the exit speed, what the batch's leaves held minus its payout, shared among its swaps in proportion to what each brought in. Each swap's payout is what it brought in minus its share. Each fee shows its BTC amount and its percentage of the swap, with ppm and USD in the tooltip. @@ -88,7 +89,7 @@ While Spark is enabled, invalid settings stop NodeGuard at startup. | `SPARK_THRESHOLD` | derived from the operator count | Operators' signing threshold | | `SPARK_SSP_URL` | Lightspark's on mainnet; required on regtest | The SSP's GraphQL endpoint | | `SPARK_SSP_IDENTITY_PUBKEY` | Lightspark's on mainnet; fetched from the SSP on regtest | Required with a custom SSP on mainnet | -| `SPARK_MAX_EXIT_FEE_SATS` | `20000` | Highest SSP exit fee accepted per swap | +| `SPARK_MAX_EXIT_FEE_SATS` | `20000` | Highest SSP exit fee accepted per exit (a batch of swaps) | | `SPARK_MAX_BALANCE_SATS` | `10000000` | Most sats a Spark wallet may hold, counting a new swap and its swaps not paid yet, unless it has its own max balance | | `SPARK_SWAP_MIN_SATS` | `50000` | A node's smallest Spark swap, at least 10000, unless the node sets its own | | `SPARK_SWAP_MAX_SATS` | `2000000` | A node's largest Spark swap, up to `MAXIMUM_SWAP_OUT_SIZE_BTC`, unless the node sets its own | @@ -118,7 +119,7 @@ With the remote signer, `REMOTE_SIGNER_ENDPOINT` must be the Function URL with n The swap monitor checks each Spark wallet after each pass. Each alert below is audited once and logged with structured fields: - `SparkBalanceStuck`: for an hour, a Spark wallet has held more than its swaps in flight account for. Until a swap completes, what it brought in (or its amount, until that is known) is its own and never counts as stuck, including while its exit waits to confirm. -- `SparkExitOverdue`: a swap was paid and still has no confirmed payout after 6 hours at `FAST`, 12 at `MEDIUM` or 24 at `SLOW` (`SPARK_EXIT_SPEED`). +- `SparkExitOverdue`: a paid swap still has no confirmed payout 6 hours at `FAST`, 12 at `MEDIUM` or 24 at `SLOW` (`SPARK_EXIT_SPEED`) after it should have moved on: after it was paid while its transfer isn't attributed, or after its exit was first attempted. A swap waiting for its wallet to fill is not late. Each wallet's balance is shown in its details on the Wallets page and exported as the `nodeguard.spark.balance` gauge (meter `NodeGuard.Spark`), tagged `spark.wallet` with the wallet's id (`remote-signer` for the remote signer's). @@ -139,7 +140,7 @@ The remote signer refuses to sign Spark operations with a `Compromised` seed. A `just spark-up` starts a regtest Spark network (three operators, the open-source open-ssp SSP and its Lightning node) on the Polar chain; see [docker/spark/README.md](../docker/spark/README.md). -`just test-e2e` and the CI `e2e-test` job run the whole E2E suite with the network up, including `SparkSwapOutE2ETests`. It creates a Spark wallet over gRPC, gives it to alice in the database with Max swaps in flight 2, restarts NodeGuard (the seed must decrypt after a restart), and starts two swaps at once. Each must exit its own leaves to its own address and record its payout, with its on-chain and service fees adding up to the amount minus the payout, and the Spark wallet must end empty. A second test holds a swap's exit (NBXplorer stopped, so no exit address can be reserved), ages its leaves to their timelock floor with the local operators' test RPC (`mock.MockService/modify_node_timelock`), then lets the exit run: it must renew the leaves (one through a node renewal, the rest through refund renewals) and move exactly them, under their own ids, with the amounts adding up. +`just test-e2e` and the CI `e2e-test` job run the whole E2E suite with the network up, including `SparkSwapOutE2ETests`. It creates a Spark wallet over gRPC with a max balance two swaps fill, gives it to alice in the database with Max swaps in flight 2 and a Spark min swap no third swap fits, restarts NodeGuard (the seed must decrypt after a restart), and starts two swaps at once. Once both are paid the wallet is full: they must exit together, in one exit to one address, each recording its share of the payout, with its on-chain and service fees adding up to the amount minus its payout, and the Spark wallet must end empty. A second test holds a swap's exit (NBXplorer stopped, so no exit address can be reserved), ages its leaves to their timelock floor with the local operators' test RPC (`mock.MockService/modify_node_timelock`), then lets the exit run: it must renew the leaves (one through a node renewal, the rest through refund renewals) and move exactly them, under their own ids, with the amounts adding up. ## Before enabling it on mainnet diff --git a/src/Data/Models/Swaps.cs b/src/Data/Models/Swaps.cs index c1255ee6..17469db0 100644 --- a/src/Data/Models/Swaps.cs +++ b/src/Data/Models/Swaps.cs @@ -200,6 +200,12 @@ public class SwapOut : Entity /// public long? SparkReceivedSats { get; set; } + /// + /// Spark swaps only: when the swap's exit was first attempted, in a batch with the other paid swaps of its Spark + /// wallet once the wallet was full. Until then the swap waits in the wallet + /// + public DateTimeOffset? SparkExitDatetime { get; set; } + /// /// What the swap paid on-chain to , once confirmed: what it delivered. /// Recorded for Spark swaps diff --git a/src/Data/Repositories/Interfaces/ISwapOutRepository.cs b/src/Data/Repositories/Interfaces/ISwapOutRepository.cs index 202f78ff..aa3488b1 100644 --- a/src/Data/Repositories/Interfaces/ISwapOutRepository.cs +++ b/src/Data/Repositories/Interfaces/ISwapOutRepository.cs @@ -42,9 +42,13 @@ public interface ISwapOutRepository Task<(bool, string?)> AddAsync(SwapOut swap); Task<(bool, string?)> AddRangeAsync(List swaps); (bool, string?) Update(SwapOut swap); + + /// Saves several swaps in one transaction: all of them, or none + (bool, string?) UpdateRange(IReadOnlyCollection swaps); /// - /// Get all swaps that are currently in flight (not completed, failed, or timed out) for a specific node, swaps older than 24 hours are ignored to avoid swaps deadlocks if many are stuck + /// Get all swaps that are currently in flight (not completed, failed, or timed out) for a specific node, swaps older than 24 hours are ignored to avoid swaps deadlocks if many are stuck. + /// A paid Spark swap is not in flight any more: it waits in its Spark wallet, whose max balance limits it /// Task> GetInFlightSwapsByNode(int nodeId); diff --git a/src/Data/Repositories/SwapOutRepository.cs b/src/Data/Repositories/SwapOutRepository.cs index c102ad10..4bf8d872 100644 --- a/src/Data/Repositories/SwapOutRepository.cs +++ b/src/Data/Repositories/SwapOutRepository.cs @@ -172,6 +172,27 @@ public async Task> GetAllPending() return _repository.Update(swap, context); } + public (bool, string?) UpdateRange(IReadOnlyCollection swaps) + { + using var context = _dbContextFactory.CreateDbContext(); + + foreach (var swap in swaps) + { + swap.SetUpdateDatetime(); + } + + try + { + // One SaveChanges: one transaction + context.UpdateRange(swaps); + return (context.SaveChanges() > 0, null); + } + catch (Exception e) + { + return (false, e.Message); + } + } + public async Task> GetInFlightSwapsByNode(int nodeId) { await using var context = await _dbContextFactory.CreateDbContextAsync(); @@ -182,7 +203,8 @@ public async Task> GetInFlightSwapsByNode(int nodeId) var swaps = await context.SwapOuts .Where(s => s.NodeId == nodeId && s.Status == SwapOutStatus.Pending && - s.CreationDatetime >= twentyFourHoursAgo) + s.CreationDatetime >= twentyFourHoursAgo && + !(s.Provider == SwapProvider.Spark && s.LightningFeeSats != null)) .ToListAsync(); return swaps; diff --git a/src/Jobs/AutoLiquidityManagementJob.cs b/src/Jobs/AutoLiquidityManagementJob.cs index b3b4d622..e2dfee55 100644 --- a/src/Jobs/AutoLiquidityManagementJob.cs +++ b/src/Jobs/AutoLiquidityManagementJob.cs @@ -222,9 +222,8 @@ public async Task Execute(IJobExecutionContext context) if (!status.IsReady) return (status.Reason ?? status.State.ToString(), 0); var balance = (await _sparkWallet.GetBalanceAsync(sparkWallet)).SatsBalance; - var unpaid = SparkGuardrails.UnpaidSats((await _swapOutRepository.GetAllPending()) + var room = SparkGuardrails.RoomSats(wallet.MaxBalanceSats, balance, (await _swapOutRepository.GetAllPending()) .Where(s => string.Equals(s.SparkIdentity, status.IdentityPublicKey, StringComparison.OrdinalIgnoreCase))); - var room = wallet.MaxBalanceSats - balance.Owned - balance.Incoming - unpaid; return room < minSwapSats ? ($"{wallet.Name} has {Math.Max(0, room)} sats of room under its max balance, below the node's Spark min swap", room) : (null, room); diff --git a/src/Jobs/SparkSwapExitJob.cs b/src/Jobs/SparkSwapExitJob.cs index f63e24d6..ee3a1878 100644 --- a/src/Jobs/SparkSwapExitJob.cs +++ b/src/Jobs/SparkSwapExitJob.cs @@ -27,10 +27,11 @@ namespace NodeGuard.Jobs; /// -/// Exits paid Spark swaps on-chain, every SPARK_EXIT_INTERVAL_MINUTES: each swap exits the leaves its own payment -/// brought in, to its own destination address. Swaps go strictly one at a time, oldest first, each step saved -/// before the next (paid, then leaves attributed, then exit sent, then payout confirmed). -/// MonitorSwapsJob follows a Spark swap until its payment settles; from then on the swap is this job's. +/// Exits paid Spark swaps on-chain, every SPARK_EXIT_INTERVAL_MINUTES. Paid swaps wait in their Spark wallet until +/// it is full, then exit together: one cooperative exit per destination wallet, of exactly the leaves their own +/// payments brought in, to one new address. Exits go strictly one at a time, oldest first, each step saved before the +/// next (paid, then leaves attributed, then exit sent, then payout confirmed). MonitorSwapsJob follows a Spark swap +/// until its payment settles; from then on the swap is this job's. /// [DisallowConcurrentExecution] public class SparkSwapExitJob : IJob @@ -58,7 +59,8 @@ public async Task Execute(IJobExecutionContext context) var ct = context.CancellationToken; try { - var swaps = (await _swapOutRepository.GetAllPending()) + var pending = await _swapOutRepository.GetAllPending(); + var swaps = pending .Where(s => s.Provider == SwapProvider.Spark && s.LightningFeeSats is not null) .OrderBy(s => s.CreationDatetime) .ThenBy(s => s.Id) @@ -70,16 +72,26 @@ public async Task Execute(IJobExecutionContext context) // Every swap gets its own transfer's leaves before any exit, so no exit takes leaves another swap is due foreach (var swap in swaps.Where(s => s.SparkTransferId is null && s.TxId is null)) { - await RunAsync(swap, "attributing its transfer", () => _sparkSwapService.AttributeAsync(swap, ct)); + await RunAsync($"Spark swap {swap.Id}", "attributing its transfer", () => _sparkSwapService.AttributeAsync(swap, ct)); } - foreach (var swap in swaps) + // Batches already exiting, the swaps sharing a reserved address: their payout is followed, or their exit + // retried with exactly them + var exiting = swaps + .Where(s => s.DestinationAddress is not null) + .GroupBy(s => (s.SparkIdentity, s.DestinationWalletId, s.DestinationAddress)) + .Select(g => (IReadOnlyList)g.ToList()) + .ToList(); + foreach (var batch in exiting) { - var step = await RunAsync(swap, "exiting", () => _sparkSwapService.ExitAsync(swap, ct)); - if (step == SparkExitStep.Completed) - { - await AuditCompletionAsync(swap); - } + await ExitAsync(batch, ct); + } + + // New batches: the paid swaps of each Spark wallet that is full + var planned = await RunAsync("Spark swaps", "planning their exits", () => _sparkSwapService.PlanExitsAsync(pending, ct)); + foreach (var batch in planned ?? []) + { + await ExitAsync(batch, ct); } } catch (Exception e) when (e is not OperationCanceledException) @@ -91,8 +103,20 @@ public async Task Execute(IJobExecutionContext context) _logger.LogInformation("{JobName} ended", nameof(SparkSwapExitJob)); } - /// Runs one swap's step; a failure is logged and leaves the swap for the next run. - private async Task RunAsync(SwapOut swap, string what, Func> step) + private async Task ExitAsync(IReadOnlyList batch, CancellationToken ct) + { + var label = batch.Count == 1 ? $"Spark swap {batch[0].Id}" : $"Spark swaps {string.Join(", ", batch.Select(s => s.Id))}"; + var step = await RunAsync(label, "exiting", () => _sparkSwapService.ExitBatchAsync(batch, ct)); + if (step != SparkExitStep.Completed) return; + + foreach (var swap in batch) + { + await AuditCompletionAsync(swap); + } + } + + /// Runs one step; a failure is logged and leaves its swaps for the next run. + private async Task RunAsync(string swaps, string what, Func> step) { try { @@ -100,7 +124,7 @@ public async Task Execute(IJobExecutionContext context) } catch (Exception e) when (e is not OperationCanceledException) { - _logger.LogError(e, "Spark swap {SwapId}: error {What}; retried on the next run", swap.Id, what); + _logger.LogError(e, "{Swaps}: error {What}; retried on the next run", swaps, what); return default; } } diff --git a/src/Migrations/20261014121804_AddSparkExitBatches.Designer.cs b/src/Migrations/20261014121804_AddSparkExitBatches.Designer.cs new file mode 100644 index 00000000..f84a347e --- /dev/null +++ b/src/Migrations/20261014121804_AddSparkExitBatches.Designer.cs @@ -0,0 +1,2024 @@ +// +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("20261014121804_AddSparkExitBatches")] + partial class AddSparkExitBatches + { + /// + 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("FortySwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("FortySwapMinAmountSats") + .HasColumnType("bigint"); + + b.Property("FortySwapWeight") + .HasColumnType("integer"); + + b.Property("FundsDestinationWalletId") + .HasColumnType("integer"); + + b.Property("IsNodeDisabled") + .HasColumnType("boolean"); + + b.Property("LoopSwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("LoopSwapMinAmountSats") + .HasColumnType("bigint"); + + 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("SparkPaymentTimeoutMinutes") + .HasColumnType("integer"); + + b.Property("SparkSwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("SparkSwapMinAmountSats") + .HasColumnType("bigint"); + + b.Property("SparkSwapWeight") + .HasColumnType("integer"); + + b.Property("SparkWalletId") + .HasColumnType("integer"); + + b.Property("SwapBudgetRefreshInterval") + .HasColumnType("interval"); + + b.Property("SwapBudgetSats") + .HasColumnType("bigint"); + + b.Property("SwapBudgetStartDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FundsDestinationWalletId"); + + b.HasIndex("PubKey") + .IsUnique(); + + b.HasIndex("SparkWalletId"); + + 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("DestinationAddress") + .HasColumnType("text"); + + 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("PaymentHash") + .HasColumnType("text"); + + b.Property("PayoutSats") + .HasColumnType("bigint"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("ServiceFeeSats") + .HasColumnType("bigint"); + + b.Property("SparkExitDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("SparkIdentity") + .HasColumnType("text"); + + b.Property("SparkInvoiceExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SparkLeafIds") + .HasColumnType("text"); + + b.Property("SparkPaymentAttempt") + .HasColumnType("integer"); + + b.Property("SparkReceivedSats") + .HasColumnType("bigint"); + + b.Property("SparkTransferId") + .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.HasKey("Id"); + + b.HasIndex("DestinationWalletId"); + + b.HasIndex("NodeId"); + + b.HasIndex("ReferenceId") + .IsUnique() + .HasFilter("\"ReferenceId\" IS NOT NULL"); + + 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("Kind") + .HasColumnType("integer"); + + b.Property("MofN") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("SparkAccount") + .HasColumnType("integer"); + + b.Property("SparkEncryptedMnemonic") + .HasColumnType("text"); + + b.Property("SparkIdentityPublicKey") + .HasColumnType("text"); + + b.Property("SparkMaxBalanceSats") + .HasColumnType("bigint"); + + 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.HasOne("NodeGuard.Data.Models.Wallet", null) + .WithMany() + .HasForeignKey("SparkWalletId") + .OnDelete(DeleteBehavior.Restrict); + + 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/20261014121804_AddSparkExitBatches.cs b/src/Migrations/20261014121804_AddSparkExitBatches.cs new file mode 100644 index 00000000..35b65524 --- /dev/null +++ b/src/Migrations/20261014121804_AddSparkExitBatches.cs @@ -0,0 +1,29 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NodeGuard.Migrations +{ + /// + public partial class AddSparkExitBatches : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SparkExitDatetime", + table: "SwapOuts", + type: "timestamp with time zone", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SparkExitDatetime", + table: "SwapOuts"); + } + } +} diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index 3e167e4d..3d8c1a11 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1261,6 +1261,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("ServiceFeeSats") .HasColumnType("bigint"); + b.Property("SparkExitDatetime") + .HasColumnType("timestamp with time zone"); + b.Property("SparkIdentity") .HasColumnType("text"); diff --git a/src/Services/Spark/SparkGuardrails.cs b/src/Services/Spark/SparkGuardrails.cs index 08694e12..31becae7 100644 --- a/src/Services/Spark/SparkGuardrails.cs +++ b/src/Services/Spark/SparkGuardrails.cs @@ -90,6 +90,13 @@ public SparkGuardrails(SparkSettings settings, ISparkWalletService spark, ILogge public static long UnpaidSats(IEnumerable pendingSwaps) => PendingSpark(pendingSwaps).Where(s => s.LightningFeeSats is null).Sum(s => s.SatsAmount); + /// + /// How many more sats a Spark wallet can take under its max balance: what it holds and what its pending swaps + /// not paid yet (, the wallet's own) will still bring in count against it. + /// + public static long RoomSats(long maxBalanceSats, SatsBalance balance, IEnumerable walletPendingSwaps) => + maxBalanceSats - balance.Owned - balance.Incoming - UnpaidSats(walletPendingSwaps); + /// /// What the wallet holds for its pending Spark swaps: what each one brought in, or its amount until it is known. /// A swap whose exit was sent still counts: its leaves stay the wallet's until the exit confirms, which at SLOW @@ -174,15 +181,25 @@ public async Task> CheckAsync(IReadOnlyCollection s.LightningFeeSats is not null)) { - var age = now - swap.CreationDatetime; + // Counted from when the swap should have moved on: its transfer is found within minutes of the payment, + // and its exit, once attempted, confirms at the exit speed. A swap waiting for its wallet to fill isn't late + var (since, what) = swap.SparkTransferId is null + ? (swap.CreationDatetime, "it was paid") + // A swap that exited before batching has an address but no exit time: counted from its creation + : (swap.SparkExitDatetime ?? (swap.DestinationAddress is null ? null : swap.CreationDatetime)) is { } exitAttempted + ? (exitAttempted, "its exit was first attempted") + : (default(DateTimeOffset?), ""); + if (since is null) continue; + + var age = now - since.Value; // A slower exit waits longer for a block: the alert waits with it if (age < _settings.ExitOverdueAfter || !_overdueAlerted.Add(swap.Id)) continue; - _logger.LogError("Spark swap {SwapId} was paid {AgeHours} hours ago and its payout is not confirmed yet (exit {TxId})", - swap.Id, (int)age.TotalHours, swap.TxId ?? "not started"); + _logger.LogError("Spark swap {SwapId} has no confirmed payout {AgeHours} hours after {What} (exit {TxId})", + swap.Id, (int)age.TotalHours, what, swap.TxId ?? "not started"); alerts.Add(new SparkAlert(AuditActionType.SparkExitOverdue, AuditObjectType.SwapOut, swap.ProviderId ?? swap.Id.ToString(), - $"Spark swap {swap.Id} has no confirmed payout {(int)age.TotalHours} hours after it was paid", - new { SwapId = swap.Id, swap.NodeId, swap.SatsAmount, swap.TxId, swap.DestinationAddress, swap.CreationDatetime })); + $"Spark swap {swap.Id} has no confirmed payout {(int)age.TotalHours} hours after {what}", + new { SwapId = swap.Id, swap.NodeId, swap.SatsAmount, swap.TxId, swap.DestinationAddress, swap.CreationDatetime, swap.SparkExitDatetime })); } return alerts; diff --git a/src/Services/Spark/SparkSettings.cs b/src/Services/Spark/SparkSettings.cs index c6c3961a..7d38b723 100644 --- a/src/Services/Spark/SparkSettings.cs +++ b/src/Services/Spark/SparkSettings.cs @@ -112,7 +112,7 @@ public sealed class SparkSettings /// The Spark identity the remote signer must serve (SPARK_IDENTITY_PUBKEY; remote signer only). public string? IdentityPublicKey { get; init; } - /// Highest fee accepted for the cooperative exit of a swap (SPARK_MAX_EXIT_FEE_SATS). + /// Highest fee accepted for a cooperative exit, of a batch of swaps (SPARK_MAX_EXIT_FEE_SATS). public long MaxExitFeeSats { get; init; } = DefaultMaxExitFeeSats; /// diff --git a/src/Services/Spark/SparkSwapService.cs b/src/Services/Spark/SparkSwapService.cs index cfdae336..1eba74b2 100644 --- a/src/Services/Spark/SparkSwapService.cs +++ b/src/Services/Spark/SparkSwapService.cs @@ -53,10 +53,11 @@ public enum SparkExitStep /// Spark swaps open at once; /// payment (, MonitorSwapsJob): the payment is followed on the node /// until it settles, which hands the swap to SparkSwapExitJob; -/// exit ( and , SparkSwapExitJob): the inbound -/// transfer that brought the payment in is found and its leaves are recorded on the swap. Exactly those -/// leaves exit on-chain to the swap's own destination address, with no leaf swap, so the remote signer can -/// sign it. The swap completes when that address receives a confirmed payout, which is what it delivered. +/// exit (, and , +/// SparkSwapExitJob): the inbound transfer that brought the payment in is found and its leaves are recorded on the +/// swap. Paid swaps wait in their Spark wallet until it is full, then exit together: one cooperative exit per +/// destination wallet, of exactly their leaves, with no leaf swap, so the remote signer can sign it. Each swap +/// completes when the batch's address receives a confirmed payout, its share of which is what it delivered. /// /// Each step is saved before the next and can be retried after a crash: the swap is recorded before the /// payment, the payment is looked up by its hash, a transfer is given to one swap only, and the payout is @@ -96,16 +97,32 @@ Task PayScheduledAsync(int swapId, string paymentHash, string paymentRequest, de Task AttributeAsync(SwapOut swap, CancellationToken ct = default); /// - /// Moves a paid swap's exit one step forward: exits its leaves to its destination address, or completes it - /// once that address has a confirmed payout. Progress is saved on . + /// The batches to exit now, oldest first, out of the attributed swaps not exiting yet among + /// (all pending Spark swaps): those of each Spark wallet that is full (no node + /// using it can add a swap under its max balance) and none of whose swaps is still being paid, one batch per + /// destination wallet, of at most leaves. /// - Task ExitAsync(SwapOut swap, CancellationToken ct = default); + Task>> PlanExitsAsync(IReadOnlyCollection pendingSwaps, CancellationToken ct = default); + + /// + /// Moves a batch's exit one step forward: exits its swaps' leaves together to one new address of their + /// destination wallet, or completes them once that address has a confirmed payout, shared among them in + /// proportion to what each brought in. A batch is either new (from ) or the swaps + /// already sharing a destination address. Progress is saved on its swaps. + /// + Task ExitBatchAsync(IReadOnlyList batch, CancellationToken ct = default); } public sealed class SparkSwapService : ISparkSwapService { public const int InvoiceExpirySeconds = 1800; + /// + /// Most leaves one cooperative exit carries. open-ssp refuses more than 100 (the operators take up to 1000); its fee + /// doesn't grow with them, so a batch packs as many swaps as fit. + /// + public const int MaxLeavesPerExit = 100; + /// /// Spark's fixed fee for a Lightning payment into Spark, in basis points: 15 (0.15%), charged to the sender on the /// Lightning route through the invoice's route hints (https://docs.spark.money/wallets/estimate-fees). It is part of @@ -199,7 +216,8 @@ public async Task CreateSwapOutAsync(Node node, SwapOut swapOut // Each swap exits its own leaves, so several can be in flight: up to the node's Max swaps in flight var limit = Math.Max(node.MaxSwapsInFlight, 1); var pending = await _swapOutRepository.GetAllPending(); - var inFlight = pending.Count(s => s.Provider == SwapProvider.Spark && s.NodeId == node.Id); + // Once paid, a swap waits in the wallet, whose max balance limits it: only swaps being paid are in flight + var inFlight = pending.Count(s => s.Provider == SwapProvider.Spark && s.NodeId == node.Id && s.LightningFeeSats is null); if (inFlight >= limit) { throw new InvalidOperationException( @@ -209,10 +227,10 @@ public async Task CreateSwapOutAsync(Node node, SwapOut swapOut // Transit only: refuse a swap that would leave more than the wallet's max balance in it, counting what // its swaps not paid yet will still bring in var balance = (await _spark.GetBalanceAsync(sparkWallet, ct)).SatsBalance; - var unpaid = SparkGuardrails.UnpaidSats(pending.Where(s => + var room = SparkGuardrails.RoomSats(entry.MaxBalanceSats, balance, pending.Where(s => string.Equals(s.SparkIdentity, status.IdentityPublicKey, StringComparison.OrdinalIgnoreCase))); - var held = balance.Owned + balance.Incoming + unpaid; - if (held + request.Amount > entry.MaxBalanceSats) + var held = entry.MaxBalanceSats - room; + if (request.Amount > room) { throw new InvalidOperationException( $"A {request.Amount} sats Spark swap would bring {entry.Name} to {held + request.Amount} sats, " + @@ -496,75 +514,183 @@ public async Task AttributeAsync(SwapOut swap, CancellationToken ct = defa return true; } - public async Task ExitAsync(SwapOut swap, CancellationToken ct = default) + public async Task>> PlanExitsAsync(IReadOnlyCollection pendingSwaps, + CancellationToken ct = default) { - RequirePaid(swap); - var wallet = await SwapWalletAsync(swap, ct); + var spark = pendingSwaps.Where(s => s.Provider == SwapProvider.Spark && s.Status == SwapOutStatus.Pending).ToList(); + var waiting = spark + .Where(s => s.LightningFeeSats is not null && s.SparkTransferId is not null && s.DestinationAddress is null && s.TxId is null) + .OrderBy(s => s.CreationDatetime) + .ThenBy(s => s.Id) + .ToList(); + if (waiting.Count == 0) return []; + + var nodes = await _nodeRepository.GetAllManagedByNodeGuard(withDisabled: false); + var batches = new List>(); + foreach (var walletSwaps in waiting.GroupBy(s => s.SparkIdentity ?? "", StringComparer.OrdinalIgnoreCase)) + { + var ofWallet = spark.Where(s => string.Equals(s.SparkIdentity, walletSwaps.Key, StringComparison.OrdinalIgnoreCase)).ToList(); + + // A swap still being paid joins this batch once paid: a full wallet takes no new swaps, so that is soon + if (ofWallet.Any(s => s.LightningFeeSats is null)) continue; + + if (await _spark.FindByIdentityAsync(walletSwaps.Key, ct) is not { } entry || + !(await _spark.EnsureReadyAsync(entry.Ref, ct)).IsReady) + { + continue; // ExitBatchAsync says why, once the wallet is back + } + + var balance = (await _spark.GetBalanceAsync(entry.Ref, ct)).SatsBalance; + var room = SparkGuardrails.RoomSats(entry.MaxBalanceSats, balance, ofWallet); + var smallestSwap = nodes + .Where(n => _settings.WalletFor(n) == entry.Ref) + .Select(n => (long?)SwapAmountLimits.For(n, SwapProvider.Spark, _settings).MinSats) + .Min(); + if (smallestSwap is { } min && room >= min) + { + _logger.LogDebug("{SparkWallet} ({Name}) has room for another swap ({Room} sats, its nodes' smallest Spark min swap {Min}): its {Count} paid swaps wait", + entry.Ref, entry.Name, room, min, walletSwaps.Count()); + continue; + } + + _logger.LogInformation("{SparkWallet} ({Name}) is full ({Room} sats of room under its max balance): its {Count} paid swaps exit", + entry.Ref, entry.Name, room, walletSwaps.Count()); + foreach (var destinationSwaps in walletSwaps.GroupBy(s => s.DestinationWalletId)) + { + batches.AddRange(Pack(destinationSwaps)); + } + } + + return batches.OrderBy(b => b[0].CreationDatetime).ThenBy(b => b[0].Id).ToList(); + } + + /// + /// Whole swaps, in order, in batches of at most leaves: a swap's leaves never split + /// across exits, so each swap has one exit and one address. A swap with more leaves than that exits alone. + /// + private static IEnumerable> Pack(IEnumerable swaps) + { + var batch = new List(); + var leaves = 0; + foreach (var swap in swaps) + { + var count = LeafIds(swap).Count; + if (batch.Count > 0 && leaves + count > MaxLeavesPerExit) + { + yield return batch; + batch = []; + leaves = 0; + } + + batch.Add(swap); + leaves += count; + } + + if (batch.Count > 0) yield return batch; + } + + public Task ExitBatchAsync(IReadOnlyList batch, CancellationToken ct = default) => + ExitBatchAsync(batch, retryWithoutStuckLeaves: true, ct); - // Done once the destination address has a confirmed payout. Checked before exiting too: an exit whose - // txid was not saved (a crash) must not be followed by a second one. No address yet means no exit yet - var payout = swap.DestinationAddress is null ? null : await FindPayoutAsync(swap, ct); + private async Task ExitBatchAsync(IReadOnlyList batch, bool retryWithoutStuckLeaves, CancellationToken ct) + { + if (batch.Count == 0) throw new ArgumentException("A batch has at least one swap.", nameof(batch)); + foreach (var swap in batch) RequirePaid(swap); + + var first = batch[0]; + if (batch.Any(s => !string.Equals(s.SparkIdentity, first.SparkIdentity, StringComparison.OrdinalIgnoreCase) || + s.DestinationWalletId != first.DestinationWalletId || s.DestinationAddress != first.DestinationAddress)) + { + throw new InvalidOperationException( + $"Spark swaps {Ids(batch)} can't exit together: their Spark wallet, destination wallet or address differ."); + } + + var wallet = await SwapWalletAsync(first, ct); + + // Done once the batch's address has a confirmed payout. Checked before exiting too: an exit whose txid was + // not saved (a crash) must not be followed by a second one. No address yet means no exit yet + var payout = first.DestinationAddress is null ? null : await FindPayoutAsync(first, ct); if (payout is not null) { if (payout.Confirmations > 0) { - Complete(swap, payout); + Complete(batch, payout); return SparkExitStep.Completed; } - if (swap.TxId != payout.TransactionId.ToString()) + var txId = payout.TransactionId.ToString(); + if (batch.Any(s => s.TxId != txId)) { - swap.TxId = payout.TransactionId.ToString(); - Save(swap); + foreach (var swap in batch) swap.TxId = txId; + SaveAll(batch); } return SparkExitStep.ExitSent; } - if (swap.TxId is not null) return SparkExitStep.ExitSent; - if (!await AttributeAsync(swap, ct)) return SparkExitStep.Waiting; + if (batch.Any(s => s.TxId is not null)) return SparkExitStep.ExitSent; + foreach (var swap in batch) + { + if (!await AttributeAsync(swap, ct)) return SparkExitStep.Waiting; + } - // The address is reserved only now, and saved before the exit: the payout is found by it after a crash - if (swap.DestinationAddress is null) + // The address is reserved only now, and saved on every swap of the batch before the exit: the payout is found + // by it after a crash, and the swaps sharing it are the batch from then on + if (first.DestinationAddress is null) { - swap.DestinationAddress = await ReserveAddressAsync(swap, ct); - if (!Save(swap)) + var address = await ReserveAddressAsync(first, ct); + var now = _time.GetUtcNow(); + foreach (var swap in batch) { - swap.DestinationAddress = null; + swap.DestinationAddress = address; + swap.SparkExitDatetime ??= now; + } + + if (!SaveAll(batch)) + { + foreach (var swap in batch) swap.DestinationAddress = null; return SparkExitStep.Waiting; } - _logger.LogInformation("Spark swap {SwapId}: exits to {Address}", swap.Id, swap.DestinationAddress); + _logger.LogInformation("Spark swaps {SwapIds}: exit together to {Address}", Ids(batch), address); } - var leafIds = LeafIds(swap); + var leafIds = batch.SelectMany(LeafIds).ToList(); WithdrawLeavesResult exit; try { - exit = await _spark.WithdrawLeavesAsync(wallet.Ref, leafIds, swap.DestinationAddress!, _settings.MaxExitFeeSats, ct); + exit = await _spark.WithdrawLeavesAsync(wallet.Ref, leafIds, first.DestinationAddress!, _settings.MaxExitFeeSats, ct); } catch (SparkLeavesNotSpendableException e) { - // Only ever the swap's own leaves. A renewal keeps a leaf's id, so they can't have moved under another one: - // either they already exited (a crash before the txid was saved), or they can't move yet (a renewal failed, - // or they are frozen), and the next run tries again - if (await AlreadyExitedAsync(swap, wallet.Ref, leafIds, ct)) + // Only ever the batch's own leaves. A renewal keeps a leaf's id, so they can't have moved under another one: + // either they already exited (a crash before the txid was saved), or some can't move yet (a renewal failed, + // or they are frozen) + if (await AlreadyExitedAsync(batch, wallet.Ref, leafIds, ct)) { - _logger.LogWarning("Spark swap {SwapId}: its leaves already exited; waiting for the payout", swap.Id); + _logger.LogWarning("Spark swaps {SwapIds}: their leaves already exited; waiting for the payout", Ids(batch)); return SparkExitStep.ExitSent; } - _logger.LogWarning("Spark swap {SwapId}: its leaves can't move yet ({Error}); trying again on the next run", - swap.Id, e.Message); - return SparkExitStep.Waiting; + // The swaps whose leaves can't move leave the batch and wait for the next run; the others exit without them + var stuck = batch.Where(s => LeafIds(s).Intersect(e.LeafIds, StringComparer.OrdinalIgnoreCase).Any()).ToList(); + var rest = batch.Except(stuck).ToList(); + _logger.LogWarning("Spark swaps {SwapIds}: their leaves can't move yet ({Error}); trying again on the next run", + stuck.Count > 0 ? Ids(stuck) : Ids(batch), e.Message); + if (stuck.Count == 0 || rest.Count == 0 || !retryWithoutStuckLeaves) return SparkExitStep.Waiting; + + foreach (var swap in stuck) swap.DestinationAddress = null; + if (!SaveAll(stuck)) return SparkExitStep.Waiting; + + return await ExitBatchAsync(rest, retryWithoutStuckLeaves: false, ct); } - swap.TxId = exit.Txid; - SetFees(swap, exit.PayoutSats); // provisional until the payout confirms - Save(swap); + foreach (var swap in batch) swap.TxId = exit.Txid; + SetFees(batch, exit.PayoutSats); // provisional until the payout confirms + SaveAll(batch); - _logger.LogInformation("Spark swap {SwapId}: cooperative exit {TxId} pays {Payout} of its {Received} sats to {Address} (exit fee {Fee} sats)", - swap.Id, exit.Txid, exit.PayoutSats, exit.SentSats, swap.DestinationAddress, exit.FeeSats); + _logger.LogInformation("Spark swaps {SwapIds}: cooperative exit {TxId} pays {Payout} of their {Received} sats to {Address} (exit fee {Fee} sats)", + Ids(batch), exit.Txid, exit.PayoutSats, exit.SentSats, first.DestinationAddress, exit.FeeSats); return SparkExitStep.ExitSent; } @@ -649,39 +775,58 @@ public async Task ExitAsync(SwapOut swap, CancellationToken ct = return transfer; } - /// Whether a cooperative exit the wallet sent since the swap was created moved any of its leaves. - private async Task AlreadyExitedAsync(SwapOut swap, SparkWalletRef wallet, IReadOnlyCollection leafIds, + /// Whether a cooperative exit the wallet sent since the batch's oldest swap was created moved any of its leaves. + private async Task AlreadyExitedAsync(IReadOnlyList batch, SparkWalletRef wallet, IReadOnlyCollection leafIds, CancellationToken ct) { - var sent = await _spark.GetTransfersAsync(wallet, TransferDirection.Sent, swap.CreationDatetime - TransferLookback, ct); + var since = batch.Min(s => s.CreationDatetime) - TransferLookback; + var sent = await _spark.GetTransfersAsync(wallet, TransferDirection.Sent, since, ct); return sent .Where(t => string.Equals(t.Type, ExitTransferType, StringComparison.OrdinalIgnoreCase)) .Any(t => t.Leaves.Any(l => leafIds.Contains(l.Id, StringComparer.OrdinalIgnoreCase))); } - private void Complete(SwapOut swap, Payout payout) + private void Complete(IReadOnlyList batch, Payout payout) { - swap.Status = SwapOutStatus.Completed; - swap.TxId = payout.TransactionId.ToString(); - swap.PayoutSats = payout.Value; - SetFees(swap, payout.Value); - Save(swap); + foreach (var swap in batch) + { + swap.Status = SwapOutStatus.Completed; + swap.TxId = payout.TransactionId.ToString(); + } + + SetFees(batch, payout.Value, payoutConfirmed: true); + SaveAll(batch); - _logger.LogInformation("Spark swap {SwapId}: completed, {Payout} sats confirmed at {Address} in {TxId} (on-chain fee {OnChainFee} sats)", - swap.Id, payout.Value, swap.DestinationAddress, swap.TxId, swap.OnChainFeeSats); + foreach (var swap in batch) + { + _logger.LogInformation("Spark swap {SwapId}: completed, {Payout} of the {BatchPayout} sats confirmed at {Address} in {TxId} (on-chain fee {OnChainFee} sats)", + swap.Id, swap.PayoutSats, payout.Value, swap.DestinationAddress, swap.TxId, swap.OnChainFeeSats); + } } /// - /// A Spark swap's fees from what it delivered on-chain. Spark's own fee is charged on the Lightning route, so it is - /// part of the routing fee (). The on-chain fee is the move from Spark to L1: - /// the SSP's exit fee and the L1 broadcast, what the swap's leaves held minus the payout. The service fee is only - /// what the payment brought in short of the amount, which receiving into Spark normally leaves at 0. + /// The fees of a batch's swaps from what their exit delivered on-chain. Spark's own fee is charged on the Lightning + /// route, so it is part of the routing fee (). The on-chain fee is the move + /// from Spark to L1: the SSP's exit fee and the L1 broadcast, what the batch's leaves held minus the payout, shared + /// in proportion to what each swap brought in (the rounding's remainder to the oldest, the batch's first). Once the + /// payout confirmed, each swap delivered what it brought in minus its share. The service fee is only what the + /// payment brought in short of the amount, which receiving into Spark normally leaves at 0. /// - private static void SetFees(SwapOut swap, long deliveredSats) + private static void SetFees(IReadOnlyList batch, long deliveredSats, bool payoutConfirmed = false) { - var received = swap.SparkReceivedSats ?? swap.SatsAmount; - swap.ServiceFeeSats = Math.Max(0, swap.SatsAmount - received); - swap.OnChainFeeSats = Math.Max(0, received - deliveredSats); + var received = batch.Select(s => s.SparkReceivedSats ?? s.SatsAmount).ToList(); + var total = received.Sum(); + var fee = Math.Max(0, total - deliveredSats); + var shares = received.Select(r => total == 0 ? 0 : (long)((decimal)fee * r / total)).ToList(); + shares[0] += fee - shares.Sum(); + + for (var i = 0; i < batch.Count; i++) + { + var swap = batch[i]; + swap.ServiceFeeSats = Math.Max(0, swap.SatsAmount - received[i]); + swap.OnChainFeeSats = shares[i]; + if (payoutConfirmed) swap.PayoutSats = received[i] - shares[i]; + } } private sealed record Payout(uint256 TransactionId, long Value, long Confirmations); @@ -751,6 +896,22 @@ private static IReadOnlyList LeafIds(SwapOut swap) => private static bool IsClaimed(SparkTransfer transfer) => string.Equals(transfer.Status, ClaimedTransferStatus, StringComparison.OrdinalIgnoreCase); + /// Saves a batch's progress in one transaction; returns whether it was saved. + private bool SaveAll(IReadOnlyCollection batch) + { + if (batch.Count == 1) return Save(batch.First()); + + var (saved, error) = _swapOutRepository.UpdateRange(batch); + if (!saved) + { + _logger.LogError("Spark swaps {SwapIds}: could not save their progress: {Error}", Ids(batch), error); + } + + return saved; + } + + private static string Ids(IEnumerable swaps) => string.Join(", ", swaps.Select(s => s.Id)); + /// Saves the swap's progress; returns whether it was saved. private bool Save(SwapOut swap) { diff --git a/src/Services/Spark/SparkWalletService.cs b/src/Services/Spark/SparkWalletService.cs index ad749342..5c38b32e 100644 --- a/src/Services/Spark/SparkWalletService.cs +++ b/src/Services/Spark/SparkWalletService.cs @@ -69,9 +69,11 @@ public sealed record SparkWalletCreation(Wallet Wallet, string Mnemonic); /// A transfer into or out of a Spark wallet, as its Transactions tab shows it. /// The wallet sent it (an exit, a withdrawal), rather than received it. /// What it was: "Lightning payment in", "Exit to on-chain", "Spark transfer", or the operators' type. -/// The Spark swap it belongs to, if any: the one it paid in, or whose leaves it exited. +/// +/// The Spark swaps it belongs to: the one it paid in, or those whose leaves it exited (a batch exits several at once). +/// public sealed record SparkWalletTransfer(string Id, DateTimeOffset Date, bool IsSent, string Type, long AmountSats, string Status, - int? SwapId); + IReadOnlyList SwapIds); /// /// NodeGuard's Spark wallets: the Spark wallet rows (SPARK_SIGNER=wallet) or the remote signer's single wallet @@ -524,7 +526,7 @@ public async Task> ListTransfersAsync(int wal { var sent = string.Equals(t.SenderIdentityPublicKey, wallet.SparkIdentityPublicKey, StringComparison.OrdinalIgnoreCase); return new SparkWalletTransfer(t.Id, t.CreatedAt, sent, TransferTypeLabel(t.Type), t.TotalValueSats, t.Status, - SwapOf(t, sent, swaps)?.Id); + SwapsOf(t, sent, swaps).Select(s => s.Id).ToList()); }) .ToList(); } @@ -539,17 +541,17 @@ public async Task> ListTransfersAsync(int wal }; /// The swap a transfer belongs to: the one it paid in, or the one whose leaves it exited. - private static SwapOut? SwapOf(SparkTransfer transfer, bool sent, IReadOnlyList swaps) + private static IEnumerable SwapsOf(SparkTransfer transfer, bool sent, IReadOnlyList swaps) { if (!sent) { - return swaps.FirstOrDefault(s => string.Equals(s.SparkTransferId, transfer.Id, StringComparison.OrdinalIgnoreCase)); + return swaps.Where(s => string.Equals(s.SparkTransferId, transfer.Id, StringComparison.OrdinalIgnoreCase)).Take(1); } var leaves = transfer.Leaves.Select(l => l.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); return leaves.Count == 0 - ? null - : swaps.FirstOrDefault(s => (s.SparkLeafIds ?? string.Empty) + ? [] + : swaps.Where(s => (s.SparkLeafIds ?? string.Empty) .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Any(leaves.Contains)); } diff --git a/src/Shared/NewSwapModal.razor b/src/Shared/NewSwapModal.razor index 39581d44..26ab3c29 100644 --- a/src/Shared/NewSwapModal.razor +++ b/src/Shared/NewSwapModal.razor @@ -537,10 +537,10 @@ $"trying routes for up to {SparkSettings.PaymentTimeoutFor(_selectedNode!)} minutes (the node's Spark payment timeout); " + "the swap shows as pending on the Swaps page meanwhile. " + SparkRetrySummary(_selectedNode!) + - "Once paid, the swap exits " + - $"on-chain to the destination wallet on its own, within about {SparkSettings.ExitIntervalOrDefault(Constants.IS_DEV_ENVIRONMENT)} minutes, " + + "Once paid, the swap waits in the Spark wallet until the wallet is full (no room left for another swap under its max balance), " + + "then exits with the wallet's other paid swaps in one on-chain transaction to the destination wallet, sharing its fee, " + $"at exit speed {SparkSettings.ExitSpeedSummary}. " + - $"Up to {maxSwapsInFlight} Spark swaps of {_selectedNode?.Name} (its Max swaps in flight) run at once.

" + + $"Up to {maxSwapsInFlight} Spark swaps of {_selectedNode?.Name} (its Max swaps in flight) are paid at once.

" + "Please confirm that you want to proceed."; await _confirmationModal.ShowModal(new Dictionary diff --git a/src/Shared/SparkWalletModals.razor b/src/Shared/SparkWalletModals.razor index 2add5805..d959d314 100644 --- a/src/Shared/SparkWalletModals.razor +++ b/src/Shared/SparkWalletModals.razor @@ -171,7 +171,7 @@ @transfer.Status - @(transfer.SwapId is { } swapId ? $"#{swapId}" : "") + @string.Join(", ", transfer.SwapIds.Select(id => $"#{id}")) @(transfer.Id.Length > 8 ? transfer.Id[..8] + "…" : transfer.Id) diff --git a/test/NodeGuard.Tests/E2E/SparkSwapOutE2ETests.cs b/test/NodeGuard.Tests/E2E/SparkSwapOutE2ETests.cs index 10422ca9..0e7d045c 100644 --- a/test/NodeGuard.Tests/E2E/SparkSwapOutE2ETests.cs +++ b/test/NodeGuard.Tests/E2E/SparkSwapOutE2ETests.cs @@ -55,13 +55,14 @@ public SparkE2EFactAttribute() /// /// A Spark swap-out through NodeGuard's gRPC API against the local Spark network (docker/spark): /// -/// a Spark wallet is created over gRPC and made alice's Spark wallet, with Max swaps in flight 2 (node -/// liquidity config, set in the database as the Nodes page would); +/// a Spark wallet is created over gRPC, with a max balance two swaps fill, and made alice's Spark wallet, with +/// Max swaps in flight 2 and a Spark min swap no third swap fits (node liquidity config, set in the database as the +/// Nodes page would); /// NodeGuard restarts, so the wallet's seed has to decrypt with the Data Protection key ring in Postgres; /// two swaps of different amounts start at once. For each, alice's LND pays an invoice of the Spark wallet, -/// MonitorSwapsJob follows the payment until it settles, SparkSwapExitJob records the transfer that brought it in -/// and exits exactly its leaves on-chain to the swap's own reserved address, and the swap completes once that -/// payout confirms, recording it; +/// MonitorSwapsJob follows the payment until it settles, and SparkSwapExitJob records the transfer that brought it +/// in. With both paid the wallet is full: their leaves exit together, in one cooperative exit to one reserved +/// address, and both complete once that payout confirms, each recording its share; /// the Spark wallet is empty again, and can't be archived while alice uses it. /// /// A second test ages a swap's leaves to their timelock floor before its exit, with the local operators' test RPC, @@ -84,7 +85,7 @@ public SparkSwapOutE2ETests(ITestOutputHelper output) : base(output) } [SparkE2EFact] - public async Task SwapOuts_ThroughASparkWallet_EachPayOutToTheirOwnAddressAndComplete() + public async Task SwapOuts_ThroughASparkWallet_ExitTogetherOnceItIsFull_AndComplete() { var client = CreateClient(out var headers); var rpc = CreateBitcoindRpc(); @@ -92,11 +93,12 @@ public async Task SwapOuts_ThroughASparkWallet_EachPayOutToTheirOwnAddressAndCom var payoutWalletId = int.Parse(Env("E2E_SPARK_PAYOUT_WALLET_ID", "2")); long[] amounts = [300_000, 200_000]; - // A Spark wallet for alice's swaps, two of which may run at once + // A Spark wallet for alice's swaps, two of which may run at once. The two fill it: 50,000 sats of room are left + // under its max balance, below alice's Spark min swap, so once both are paid they exit together var sparkWallet = await client.CreateSparkWalletAsync( - new CreateSparkWalletRequest { Name = $"e2e transit {Guid.NewGuid():N}"[..20], MaxBalanceSats = 2_000_000 }, headers); + new CreateSparkWalletRequest { Name = $"e2e transit {Guid.NewGuid():N}"[..20], MaxBalanceSats = 550_000 }, headers); _output.WriteLine($"Spark wallet {sparkWallet.WalletId}: identity {sparkWallet.IdentityPublicKey}"); - await SetSparkConfigAsync(alice.Id, sparkWallet.WalletId, maxSwapsInFlight: 2); + await SetSparkConfigAsync(alice.Id, sparkWallet.WalletId, maxSwapsInFlight: 2, sparkMinSwapSats: 100_000); // Its seed must still decrypt after a restart, with the key ring NodeGuard keeps in Postgres await RestartNodeGuardAsync(); @@ -120,8 +122,8 @@ public async Task SwapOuts_ThroughASparkWallet_EachPayOutToTheirOwnAddressAndCom swap.DestinationAddress.Should().BeEmpty("a Spark swap reserves its address when it exits"); } - // MonitorSwapsJob sees the payments settle and SparkSwapExitJob exits each swap's leaves (each every minute in - // a dev environment); mining confirms the exits + // MonitorSwapsJob sees the payments settle and SparkSwapExitJob exits the swaps' leaves together (each every + // minute in a dev environment); mining confirms the exit var done = await PollAsync( async () => { @@ -140,22 +142,23 @@ public async Task SwapOuts_ThroughASparkWallet_EachPayOutToTheirOwnAddressAndCom swap.Status.Should().Be(SWAP_OUT_STATUS.SwapOutCompleted, swap.Error); swap.SwapId.Should().Be(started[i].SwapId); - swap.DestinationAddress.Should().NotBeNullOrEmpty("the swap reserved its address when it exited"); + swap.DestinationAddress.Should().NotBeNullOrEmpty("the batch reserved its address when it exited"); swap.HasPaymentHash.Should().BeTrue(); - - // What landed on-chain is what the swap delivered. The exit from Spark to L1 is the on-chain fee, and any - // shortfall of the payment the service fee (Spark's own 15 bps is in the routing fee) - var destination = BitcoinAddress.Create(swap.DestinationAddress, Network.RegTest); - var payoutTx = await rpc.GetRawTransactionAsync(uint256.Parse(swap.TxId)); - var payout = payoutTx.Outputs.Where(o => o.ScriptPubKey == destination.ScriptPubKey).Sum(o => o.Value.Satoshi); swap.HasPayoutSats.Should().BeTrue(); - swap.PayoutSats.Should().Be(payout); - (swap.OnchainFeeSats + swap.ServiceFeeSats).Should().Be(amounts[i] - payout, + + // Each swap's share of the payout is what it delivered. The exit from Spark to L1 is the on-chain fee, and + // any shortfall of the payment the service fee (Spark's own 15 bps is in the routing fee) + (swap.OnchainFeeSats + swap.ServiceFeeSats).Should().Be(amounts[i] - swap.PayoutSats, "the on-chain and service fees are everything the swap did not deliver"); } - done.Select(s => s.DestinationAddress).Should().OnlyHaveUniqueItems("each swap pays out to its own address"); - done.Select(s => s.TxId).Should().OnlyHaveUniqueItems("each swap exits on its own"); + // One exit for both, to one address: together they delivered what landed there + done.Select(s => s.TxId).Distinct().Should().ContainSingle("the full wallet's swaps exit together"); + done.Select(s => s.DestinationAddress).Distinct().Should().ContainSingle("a batch pays out to one address"); + var destination = BitcoinAddress.Create(done[0].DestinationAddress, Network.RegTest); + var payoutTx = await rpc.GetRawTransactionAsync(uint256.Parse(done[0].TxId)); + var payout = payoutTx.Outputs.Where(o => o.ScriptPubKey == destination.ScriptPubKey).Sum(o => o.Value.Satoshi); + done.Sum(s => s.PayoutSats).Should().Be(payout, "each swap's payout is its share of the exit's"); // Asking again with the same reference_id returns the same swap rather than paying again var again = await client.RequestSwapOutAsync(requests[0], headers); @@ -187,10 +190,11 @@ public async Task SwapOut_LeavesAtTheirTimelockFloor_AreRenewedAndExitedUnderThe var alice = (await WaitForNodesAsync(client, headers)).Single(n => n.Name == "alice"); const long amount = 300_000; + // The one swap fills the wallet, so it exits as soon as it can var sparkWallet = await client.CreateSparkWalletAsync( - new CreateSparkWalletRequest { Name = $"e2e renew {Guid.NewGuid():N}"[..20], MaxBalanceSats = 2_000_000 }, headers); + new CreateSparkWalletRequest { Name = $"e2e renew {Guid.NewGuid():N}"[..20], MaxBalanceSats = 350_000 }, headers); _output.WriteLine($"Spark wallet {sparkWallet.WalletId}: identity {sparkWallet.IdentityPublicKey}"); - await SetSparkConfigAsync(alice.Id, sparkWallet.WalletId, maxSwapsInFlight: 1); + await SetSparkConfigAsync(alice.Id, sparkWallet.WalletId, maxSwapsInFlight: 1, sparkMinSwapSats: 100_000); var request = new RequestSwapOutRequest { @@ -309,16 +313,21 @@ private async Task StartSwapAsync(NodeGuardService.NodeG } } - /// A node's Spark wallet and Max swaps in flight are liquidity config, set on the Nodes page; there is no RPC for them - private static async Task SetSparkConfigAsync(int nodeId, int sparkWalletId, int maxSwapsInFlight) + /// + /// A node's Spark wallet, Max swaps in flight and Spark min swap are liquidity config, set on the Nodes page; there is + /// no RPC for them + /// + private static async Task SetSparkConfigAsync(int nodeId, int sparkWalletId, int maxSwapsInFlight, long sparkMinSwapSats) { await using var connection = new NpgsqlConnection(Env("POSTGRES_CONNECTIONSTRING", "Host=localhost;Port=5432;Database=nodeguard;User ID=postgres;")); await connection.OpenAsync(); await using var command = new NpgsqlCommand( - "UPDATE \"Nodes\" SET \"SparkWalletId\" = @wallet, \"MaxSwapsInFlight\" = @maxSwapsInFlight WHERE \"Id\" = @node", connection); + "UPDATE \"Nodes\" SET \"SparkWalletId\" = @wallet, \"MaxSwapsInFlight\" = @maxSwapsInFlight, " + + "\"SparkSwapMinAmountSats\" = @sparkMinSwap WHERE \"Id\" = @node", connection); command.Parameters.AddWithValue("wallet", sparkWalletId); command.Parameters.AddWithValue("maxSwapsInFlight", maxSwapsInFlight); + command.Parameters.AddWithValue("sparkMinSwap", sparkMinSwapSats); command.Parameters.AddWithValue("node", nodeId); (await command.ExecuteNonQueryAsync()).Should().Be(1); } diff --git a/test/NodeGuard.Tests/Jobs/SparkSwapExitJobTests.cs b/test/NodeGuard.Tests/Jobs/SparkSwapExitJobTests.cs index efd1a6fd..b75a235d 100644 --- a/test/NodeGuard.Tests/Jobs/SparkSwapExitJobTests.cs +++ b/test/NodeGuard.Tests/Jobs/SparkSwapExitJobTests.cs @@ -39,14 +39,15 @@ public SparkSwapExitJobTests() { _context.CancellationToken.Returns(CancellationToken.None); _sparkSwaps.AttributeAsync(Arg.Any(), Arg.Any()).Returns(true); - _sparkSwaps.ExitAsync(Arg.Any(), Arg.Any()).Returns(SparkExitStep.ExitSent); + _sparkSwaps.ExitBatchAsync(Arg.Any>(), Arg.Any()).Returns(SparkExitStep.ExitSent); + Plans(); } private SparkSwapExitJob Job(bool sparkEnabled = true) => new(NullLogger.Instance, _swapOuts, _sparkSwaps, _audit, sparkEnabled ? new SparkSettings { Enabled = true } : SparkSettings.Disabled); private static SwapOut Swap(int id, int minutes, long? lightningFee = 10, string? transferId = null, - SwapProvider provider = SwapProvider.Spark) => new() + SwapProvider provider = SwapProvider.Spark, string? address = null) => new() { Id = id, Provider = provider, @@ -55,61 +56,88 @@ private static SwapOut Swap(int id, int minutes, long? lightningFee = 10, string SatsAmount = 100_000, LightningFeeSats = lightningFee, SparkTransferId = transferId, + SparkIdentity = "02aa", + DestinationWalletId = 3, + DestinationAddress = address, CreationDatetime = DateTimeOffset.UnixEpoch.AddMinutes(minutes) }; + /// The batches the service plans to exit now. + private void Plans(params IReadOnlyList[] batches) => + _sparkSwaps.PlanExitsAsync(Arg.Any>(), Arg.Any()) + .Returns(batches.ToList()); + + private static IReadOnlyList Batch(params SwapOut[] swaps) => + Arg.Is>(b => b.SequenceEqual(swaps)); + [Fact] - public async Task Execute_AttributesEveryPaidSwap_ThenExitsThemOneAtATime_OldestFirst() + public async Task Execute_AttributesEveryPaidSwap_FollowsTheBatchesExiting_ThenExitsThePlannedOnes() { - var newer = Swap(2, minutes: 5); - var older = Swap(1, minutes: 1); - var attributed = Swap(3, minutes: 3, transferId: "transfer-3"); + var exitingOlder = Swap(1, minutes: 1, transferId: "transfer-1", address: "bcrt1-batch"); + var exitingNewer = Swap(3, minutes: 3, transferId: "transfer-3", address: "bcrt1-batch"); + var waiting = Swap(2, minutes: 5); var unpaid = Swap(4, minutes: 0, lightningFee: null); var loop = Swap(5, minutes: 0, provider: SwapProvider.Loop); - _swapOuts.GetAllPending().Returns(new List { newer, unpaid, attributed, loop, older }); + var pending = new List { waiting, unpaid, exitingNewer, loop, exitingOlder }; + _swapOuts.GetAllPending().Returns(pending); + Plans(new List { waiting }); await Job().Execute(_context); Received.InOrder(() => { - _sparkSwaps.AttributeAsync(older, Arg.Any()); - _sparkSwaps.AttributeAsync(newer, Arg.Any()); - _sparkSwaps.ExitAsync(older, Arg.Any()); - _sparkSwaps.ExitAsync(attributed, Arg.Any()); - _sparkSwaps.ExitAsync(newer, Arg.Any()); + _sparkSwaps.AttributeAsync(waiting, Arg.Any()); + _sparkSwaps.ExitBatchAsync(Batch(exitingOlder, exitingNewer), Arg.Any()); + _sparkSwaps.PlanExitsAsync(pending, Arg.Any()); + _sparkSwaps.ExitBatchAsync(Batch(waiting), Arg.Any()); }); - await _sparkSwaps.DidNotReceive().AttributeAsync(attributed, Arg.Any()); - await _sparkSwaps.DidNotReceive().ExitAsync(unpaid, Arg.Any()); - await _sparkSwaps.DidNotReceive().ExitAsync(loop, Arg.Any()); + await _sparkSwaps.DidNotReceive().AttributeAsync(exitingOlder, Arg.Any()); + await _sparkSwaps.Received(2).ExitBatchAsync(Arg.Any>(), Arg.Any()); } [Fact] - public async Task Execute_AuditsACompletedSwap() + public async Task Execute_AuditsEverySwapOfACompletedBatch() { - var swap = Swap(1, minutes: 1, transferId: "transfer-1"); - _swapOuts.GetAllPending().Returns(new List { swap }); - _sparkSwaps.ExitAsync(swap, Arg.Any()).Returns(SparkExitStep.Completed); + var first = Swap(1, minutes: 1, transferId: "transfer-1", address: "bcrt1-batch"); + var second = Swap(2, minutes: 2, transferId: "transfer-2", address: "bcrt1-batch"); + _swapOuts.GetAllPending().Returns(new List { first, second }); + _sparkSwaps.ExitBatchAsync(Batch(first, second), Arg.Any()).Returns(SparkExitStep.Completed); await Job().Execute(_context); await _audit.Received(1).LogSystemAsync(AuditActionType.SwapOutCompleted, AuditEventType.Success, AuditObjectType.SwapOut, "req-1", Arg.Any()); + await _audit.Received(1).LogSystemAsync(AuditActionType.SwapOutCompleted, AuditEventType.Success, AuditObjectType.SwapOut, + "req-2", Arg.Any()); } [Fact] - public async Task Execute_ASwapThatFails_DoesNotStopTheOthers() + public async Task Execute_ABatchThatFails_DoesNotStopTheOthers() { - var failing = Swap(1, minutes: 1, transferId: "transfer-1"); - var next = Swap(2, minutes: 2, transferId: "transfer-2"); + var failing = Swap(1, minutes: 1, transferId: "transfer-1", address: "bcrt1-one"); + var next = Swap(2, minutes: 2, transferId: "transfer-2", address: "bcrt1-two"); _swapOuts.GetAllPending().Returns(new List { failing, next }); - _sparkSwaps.ExitAsync(failing, Arg.Any()).ThrowsAsync(new InvalidOperationException("the SSP is down")); + _sparkSwaps.ExitBatchAsync(Batch(failing), Arg.Any()).ThrowsAsync(new InvalidOperationException("the SSP is down")); await Job().Execute(_context); - await _sparkSwaps.Received(1).ExitAsync(next, Arg.Any()); + await _sparkSwaps.Received(1).ExitBatchAsync(Batch(next), Arg.Any()); await _audit.DidNotReceiveWithAnyArgs().LogSystemAsync(default, default, default, default, default); } + [Fact] + public async Task Execute_WhenPlanningFails_StillFollowsTheBatchesExiting() + { + var exiting = Swap(1, minutes: 1, transferId: "transfer-1", address: "bcrt1-batch"); + _swapOuts.GetAllPending().Returns(new List { exiting }); + _sparkSwaps.PlanExitsAsync(Arg.Any>(), Arg.Any()) + .ThrowsAsync(new InvalidOperationException("the wallet is down")); + + await Job().Execute(_context); + + await _sparkSwaps.Received(1).ExitBatchAsync(Batch(exiting), Arg.Any()); + } + [Fact] public async Task Execute_WithSparkDisabled_DoesNothing() { diff --git a/test/NodeGuard.Tests/Services/Spark/SparkGuardrailsTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkGuardrailsTests.cs index 85f81423..3ab24d44 100644 --- a/test/NodeGuard.Tests/Services/Spark/SparkGuardrailsTests.cs +++ b/test/NodeGuard.Tests/Services/Spark/SparkGuardrailsTests.cs @@ -206,6 +206,34 @@ public async Task APaidSwapWithoutAConfirmedPayout_IsAlertedOnceWhenOverdueForIt (await _guardrails.CheckAsync([swap])).Should().BeEmpty(); } + [Fact] + public async Task ASwapWaitingForItsWalletToFill_IsNotOverdue() + { + var swap = PaidSparkSwap(_time.GetUtcNow(), received: 499_000); + swap.SparkTransferId = "transfer-5"; + Holds(A, 499_000); + + _time.Advance(TimeSpan.FromDays(3)); + + (await _guardrails.CheckAsync([swap])).Should().BeEmpty(); + } + + [Fact] + public async Task AnAttemptedExit_IsOverdueCountingFromItsFirstAttempt() + { + var swap = PaidSparkSwap(_time.GetUtcNow(), received: 499_000); + swap.SparkTransferId = "transfer-5"; + swap.SparkExitDatetime = _time.GetUtcNow().AddDays(2); + Holds(A, 499_000); + + _time.Advance(TimeSpan.FromDays(2) + TimeSpan.FromHours(24) - TimeSpan.FromMinutes(1)); + (await _guardrails.CheckAsync([swap])).Should().BeEmpty(); + + _time.Advance(TimeSpan.FromMinutes(1)); + (await _guardrails.CheckAsync([swap])).Should().ContainSingle() + .Which.Message.Should().Contain("after its exit was first attempted"); + } + [Fact] public async Task AnUnpaidSwap_IsNotAnOverdueExit() { diff --git a/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs index 608fdbc2..494f0366 100644 --- a/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs +++ b/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs @@ -249,6 +249,19 @@ public async Task Create_RecordsTheSwap_ThenStartsItsPayment_WithoutWaitingForIt response.Status.Should().Be(SwapOutStatus.Pending); } + [Fact] + public async Task Create_APaidSwapWaitingInTheWallet_IsNotInFlight() + { + InvoiceIssued(); + _node.MaxSwapsInFlight = 1; + _swapOuts.GetAllPending().Returns(new List { Pending(lightningFee: 12, leafIds: "l1", address: null) }); + Balance(available: 0); + + var (_, saved, _) = await Service().CreateSwapOutAsync(_node, Template(), Request()); + + saved.Should().BeTrue(); + } + [Fact] public async Task Create_PaysWithTheNodesOwnPaymentTimeout() { @@ -496,7 +509,8 @@ public async Task Pay_WhenPayingThrows_LeavesItToTheMonitor() public async Task Create_AtTheNodesMaxSwapsInFlight_IsRefused(int maxSwapsInFlight, int inFlight) { _node.MaxSwapsInFlight = maxSwapsInFlight; - var pending = Enumerable.Range(10, inFlight).Select(id => Pending(id: id)).ToList(); + // Swaps being paid: a paid one waits in the wallet and is no longer in flight + var pending = Enumerable.Range(10, inFlight).Select(id => Pending(id: id, lightningFee: null)).ToList(); pending.Add(new SwapOut { Id = 30, NodeId = 2, Provider = SwapProvider.Spark, Status = SwapOutStatus.Pending }); pending.Add(new SwapOut { Id = 31, NodeId = 1, Provider = SwapProvider.Loop, Status = SwapOutStatus.Pending }); _swapOuts.GetAllPending().Returns(pending); @@ -795,6 +809,179 @@ public async Task Attribute_AClaimThatFailed_IsRetriedOnTheNextRun_AndTheTransfe // ── Exit ───────────────────────────────────────────────────────────────────────────── + private Task Exit(params SwapOut[] batch) => Service().ExitBatchAsync(batch); + + /// A second paid, attributed swap of the same wallet and destination: 300,000 sats in leaf l3. + private static SwapOut Second(string? address = Address, string? txId = null) + { + var swap = Pending(id: 8, leafIds: "l3", address: address, txId: txId); + swap.SatsAmount = 300_000; + swap.SparkReceivedSats = 300_000; + swap.CreationDatetime = DateTimeOffset.UnixEpoch.AddMinutes(1); + return swap; + } + + [Fact] + public async Task Exit_ABatch_ExitsAllItsSwapsLeaves_ToOneAddress_SavedOnEverySwapFirst() + { + var first = Pending(leafIds: "l1,l2", address: null); + var second = Second(address: null); + AddressReserved(Address); + _swapOuts.UpdateRange(Arg.Any>()).Returns((true, null)); + _spark.WithdrawLeavesAsync(W, Arg.Any>(), Address, 20_000, Arg.Any()) + .Returns(new WithdrawLeavesResult("exit-tx", 799_000, 789_000)); + + (await Exit(first, second)).Should().Be(SparkExitStep.ExitSent); + + NSubstitute.Received.InOrder(() => + { + _swapOuts.UpdateRange(Arg.Is>(b => b.All(sw => sw.DestinationAddress == Address))); + _spark.WithdrawLeavesAsync(W, Arg.Is>(ids => ids.SequenceEqual(new[] { "l1", "l2", "l3" })), + Address, 20_000, Arg.Any()); + _swapOuts.UpdateRange(Arg.Is>(b => b.All(sw => sw.TxId == "exit-tx"))); + }); + await _nbXplorer.Received(1).GetUnusedAsync(Arg.Any(), + NBXplorer.DerivationStrategy.DerivationFeature.Deposit, 0, true, Arg.Any()); + new[] { first, second }.Should().AllSatisfy(sw => + { + sw.TxId.Should().Be("exit-tx"); + sw.DestinationAddress.Should().Be(Address); + sw.SparkExitDatetime.Should().Be(_time.GetUtcNow()); + }); + // 10,000 sats of exit fee, shared by what each brought in: 499/799 and 300/799, the rounding's sat to the oldest + first.OnChainFeeSats.Should().Be(6_246); + second.OnChainFeeSats.Should().Be(3_754); + } + + [Fact] + public async Task Exit_ABatchsConfirmedPayout_CompletesEverySwap_WithItsShare() + { + var first = Pending(leafIds: "l1,l2", txId: "exit-tx"); + var second = Second(txId: "exit-tx"); + _swapOuts.UpdateRange(Arg.Any>()).Returns((true, null)); + Payouts((7, 789_000, 1)); + + (await Exit(first, second)).Should().Be(SparkExitStep.Completed); + + first.Should().BeEquivalentTo(new { Status = SwapOutStatus.Completed, PayoutSats = (long?)492_754, OnChainFeeSats = (long?)6_246 }); + second.Should().BeEquivalentTo(new { Status = SwapOutStatus.Completed, PayoutSats = (long?)296_246, OnChainFeeSats = (long?)3_754 }); + (first.PayoutSats + second.PayoutSats).Should().Be(789_000, "together they delivered the exit's payout"); + _swapOuts.Received(1).UpdateRange(Arg.Is>(b => b.Count == 2)); + } + + [Fact] + public async Task Exit_SwapsWhoseLeavesCannotMove_LeaveTheBatch_AndTheOthersExit() + { + var first = Pending(leafIds: "l1,l2", address: null); + var stuck = Second(address: null); + AddressReserved(Address); + _swapOuts.UpdateRange(Arg.Any>()).Returns((true, null)); + _spark.WithdrawLeavesAsync(W, Arg.Is>(ids => ids.Count == 3), Address, 20_000, Arg.Any()) + .ThrowsAsync(new SparkLeavesNotSpendableException("withdraw_leaves", ["l3"])); + _spark.WithdrawLeavesAsync(W, Arg.Is>(ids => ids.SequenceEqual(new[] { "l1", "l2" })), Address, 20_000, + Arg.Any()) + .Returns(new WithdrawLeavesResult("exit-tx", 499_000, 497_000)); + + (await Exit(first, stuck)).Should().Be(SparkExitStep.ExitSent); + + first.TxId.Should().Be("exit-tx"); + stuck.TxId.Should().BeNull(); + stuck.DestinationAddress.Should().BeNull("it waits for another batch"); + stuck.SparkExitDatetime.Should().NotBeNull("its exit was attempted: the overdue alert counts from then"); + } + + [Fact] + public async Task Exit_SwapsThatCannotShareAnExit_AreRefused() + { + var other = Second(); + other.DestinationWalletId = 4; + + var act = () => Exit(Pending(leafIds: "l1,l2"), other); + + await act.Should().ThrowAsync().WithMessage("*can't exit together*"); + await _spark.DidNotReceiveWithAnyArgs().WithdrawLeavesAsync(default, default!, default!, default, default); + } + + // ── Planning the exits ─────────────────────────────────────────────────────────────── + + /// alice uses W, with a Spark min swap of 100,000 sats; W holds up to 1,000,000. + private void PlanFixture(long ownedSats) + { + _node.SparkSwapMinAmountSats = 100_000; + SparkWallet(maxBalanceSats: 1_000_000); + Balance(available: ownedSats); + _nodes.GetAllManagedByNodeGuard(false).Returns(new List { _node }); + } + + [Fact] + public async Task Plan_AFullWallet_ExitsItsPaidSwaps_OneBatchPerDestinationWallet() + { + PlanFixture(ownedSats: 950_000); // 50,000 sats of room: no 100,000-sat swap fits + var a = Pending(id: 1, leafIds: "a1", address: null); + var b = Pending(id: 2, leafIds: "b1", address: null); + var c = Pending(id: 3, leafIds: "c1", address: null); + c.DestinationWalletId = 4; + + var batches = await Service().PlanExitsAsync([b, c, a]); + + batches.Should().HaveCount(2); + batches[0].Should().Equal(a, b); + batches[1].Should().Equal(c); + } + + [Fact] + public async Task Plan_AWalletWithRoomForAnotherSwap_LetsItsSwapsWait() + { + PlanFixture(ownedSats: 899_999); // 100,001 sats of room + + (await Service().PlanExitsAsync([Pending(leafIds: "l1", address: null)])).Should().BeEmpty(); + } + + [Fact] + public async Task Plan_WhileASwapOfTheWalletIsBeingPaid_ItsSwapsWaitForIt() + { + PlanFixture(ownedSats: 950_000); + + var batches = await Service().PlanExitsAsync([Pending(leafIds: "l1", address: null), Pending(id: 9, lightningFee: null)]); + + batches.Should().BeEmpty(); + } + + [Fact] + public async Task Plan_AWalletNoNodeUses_IsFull() + { + PlanFixture(ownedSats: 0); + _nodes.GetAllManagedByNodeGuard(false).Returns(new List()); + var swap = Pending(leafIds: "l1", address: null); + + (await Service().PlanExitsAsync([swap])).Should().ContainSingle().Which.Should().Equal(swap); + } + + [Fact] + public async Task Plan_ABatchCarriesAtMostOneHundredLeaves_NeverSplittingASwap() + { + PlanFixture(ownedSats: 950_000); + string Leaves(string prefix, int count) => string.Join(',', Enumerable.Range(0, count).Select(i => $"{prefix}{i}")); + var a = Pending(id: 1, leafIds: Leaves("a", 60), address: null); + var b = Pending(id: 2, leafIds: Leaves("b", 30), address: null); + var c = Pending(id: 3, leafIds: Leaves("c", 20), address: null); + + var batches = await Service().PlanExitsAsync([a, b, c]); + + batches.Select(batch => batch.Select(sw => sw.Id)).Should().BeEquivalentTo(new[] { new[] { 1, 2 }, new[] { 3 } }, + o => o.WithStrictOrdering()); + } + + [Fact] + public async Task Plan_LeavesOutSwapsAlreadyExitingOrNotAttributed() + { + PlanFixture(ownedSats: 950_000); + + var batches = await Service().PlanExitsAsync([Pending(leafIds: "l1"), Pending(id: 9, address: null)]); + + batches.Should().BeEmpty(); + } + [Fact] public async Task Exit_ExitsExactlyTheSwapsLeaves_ToItsOwnAddress() { @@ -802,7 +989,7 @@ public async Task Exit_ExitsExactlyTheSwapsLeaves_ToItsOwnAddress() _spark.WithdrawLeavesAsync(W, Arg.Any>(), Address, 20_000, Arg.Any()) .Returns(new WithdrawLeavesResult("exit-tx", 499_000, 497_000)); - var step = await Service().ExitAsync(swap); + var step = await Exit(swap); step.Should().Be(SparkExitStep.ExitSent); swap.TxId.Should().Be("exit-tx"); @@ -823,7 +1010,7 @@ public async Task Exit_ReservesItsAddressOnceAttributed_AndSavesItBeforeExiting( _spark.WithdrawLeavesAsync(W, Arg.Any>(), Address, 20_000, Arg.Any()) .Returns(new WithdrawLeavesResult("exit-tx", 499_000, 497_000)); - (await Service().ExitAsync(swap)).Should().Be(SparkExitStep.ExitSent); + (await Exit(swap)).Should().Be(SparkExitStep.ExitSent); swap.DestinationAddress.Should().Be(Address); NSubstitute.Received.InOrder(() => @@ -841,7 +1028,7 @@ public async Task Exit_BeforeAttribution_ReservesNoAddress() { SspReports(null); - (await Service().ExitAsync(Pending(address: null))).Should().Be(SparkExitStep.Waiting); + (await Exit(Pending(address: null))).Should().Be(SparkExitStep.Waiting); await _nbXplorer.DidNotReceiveWithAnyArgs().GetUnusedAsync(default!, default, default, default, default); await _nbXplorer.DidNotReceiveWithAnyArgs().GetTransactionsAsync(default!); @@ -854,7 +1041,7 @@ public async Task Exit_WhenItsAddressCannotBeSaved_DoesNotExit() AddressReserved(Address); _swapOuts.Update(swap).Returns((false, "the database is down")); - (await Service().ExitAsync(swap)).Should().Be(SparkExitStep.Waiting); + (await Exit(swap)).Should().Be(SparkExitStep.Waiting); swap.DestinationAddress.Should().BeNull(); await _spark.DidNotReceiveWithAnyArgs().WithdrawLeavesAsync(default, default!, default!, default, default); @@ -870,7 +1057,7 @@ public async Task Exit_AnUnattributedSwap_IsAttributedFirst() _spark.WithdrawLeavesAsync(W, Arg.Any>(), Address, 20_000, Arg.Any()) .Returns(new WithdrawLeavesResult("exit-tx", 499_000, 497_000)); - (await Service().ExitAsync(swap)).Should().Be(SparkExitStep.ExitSent); + (await Exit(swap)).Should().Be(SparkExitStep.ExitSent); swap.SparkLeafIds.Should().Be("transfer-1-leaf"); swap.TxId.Should().Be("exit-tx"); @@ -881,7 +1068,7 @@ public async Task Exit_BeforeTheTransferArrived_Waits() { SspReports(null); - (await Service().ExitAsync(Pending())).Should().Be(SparkExitStep.Waiting); + (await Exit(Pending())).Should().Be(SparkExitStep.Waiting); await _spark.DidNotReceiveWithAnyArgs().WithdrawLeavesAsync(default, default!, default!, default, default); } @@ -893,7 +1080,7 @@ public async Task Exit_WhileTheTransferIsNotClaimed_Waits() SspReports("transfer-1"); _spark.GetTransferAsync(W, "transfer-1", Arg.Any()).Returns(Unclaimed(Transfer("transfer-1", 499_000))); - (await Service().ExitAsync(swap)).Should().Be(SparkExitStep.Waiting); + (await Exit(swap)).Should().Be(SparkExitStep.Waiting); swap.SparkLeafIds.Should().BeNull(); await _spark.DidNotReceiveWithAnyArgs().WithdrawLeavesAsync(default, default!, default!, default, default); @@ -907,7 +1094,7 @@ public async Task Exit_LeavesThatCannotMoveYet_Wait_WithoutTouchingAnyOtherLeave _spark.WithdrawLeavesAsync(W, Arg.Any>(), Address, 20_000, Arg.Any()) .ThrowsAsync(new SparkLeavesNotSpendableException("withdraw_leaves", ["l1"])); - (await Service().ExitAsync(swap)).Should().Be(SparkExitStep.Waiting); + (await Exit(swap)).Should().Be(SparkExitStep.Waiting); swap.SparkLeafIds.Should().Be("l1,l2"); swap.TxId.Should().BeNull(); @@ -927,7 +1114,7 @@ public async Task Exit_LeavesThatAlreadyExited_AreNotExitedAgain() _spark.GetTransfersAsync(W, TransferDirection.Sent, Arg.Any(), Arg.Any()) .Returns(new List { Transfer("exit", 499_000, "CooperativeExit", Leaf("l2", 236_856)) }); - (await Service().ExitAsync(swap)).Should().Be(SparkExitStep.ExitSent); + (await Exit(swap)).Should().Be(SparkExitStep.ExitSent); await _spark.Received(1).WithdrawLeavesAsync(W, Arg.Any>(), Address, 20_000, Arg.Any()); swap.SparkLeafIds.Should().Be("l1,l2"); @@ -940,7 +1127,7 @@ public async Task Exit_AConfirmedPayout_CompletesTheSwap_WithWhatLandedOnChain() var swap = Pending(txId: "exit-tx", leafIds: "l1"); swap.SparkReceivedSats = 500_000; // receiving into Spark is free: Spark's fee was on the Lightning route - (await Service().ExitAsync(swap)).Should().Be(SparkExitStep.Completed); + (await Exit(swap)).Should().Be(SparkExitStep.Completed); swap.Status.Should().Be(SwapOutStatus.Completed); swap.TxId.Should().Be(new uint256(Enumerable.Repeat((byte)0xbb, 32).ToArray()).ToString(), "the SSP may fee-bump the exit"); @@ -958,7 +1145,7 @@ public async Task Exit_AnUnconfirmedPayout_WaitsForItsConfirmation() Payouts((Seed: 0xbb, Sats: 497_500, Confirmations: 0)); var swap = Pending(txId: "exit-tx", leafIds: "l1"); - (await Service().ExitAsync(swap)).Should().Be(SparkExitStep.ExitSent); + (await Exit(swap)).Should().Be(SparkExitStep.ExitSent); swap.Status.Should().Be(SwapOutStatus.Pending); swap.TxId.Should().Be(new uint256(Enumerable.Repeat((byte)0xbb, 32).ToArray()).ToString()); @@ -970,7 +1157,7 @@ public async Task Exit_APayoutWhoseExitWasNotSaved_IsNotExitedAgain() { Payouts((Seed: 0xcc, Sats: 497_000, Confirmations: 0)); - (await Service().ExitAsync(Pending(txId: null, leafIds: "l1"))).Should().Be(SparkExitStep.ExitSent); + (await Exit(Pending(txId: null, leafIds: "l1"))).Should().Be(SparkExitStep.ExitSent); await _spark.DidNotReceiveWithAnyArgs().WithdrawLeavesAsync(default, default!, default!, default, default); } @@ -978,7 +1165,7 @@ public async Task Exit_APayoutWhoseExitWasNotSaved_IsNotExitedAgain() [Fact] public async Task Exit_ASwapPaidIntoAnotherSparkIdentity_IsRefused() { - var act = () => Service().ExitAsync(Pending(identity: Ssp, leafIds: "l1")); + var act = () => Exit(Pending(identity: Ssp, leafIds: "l1")); await act.Should().ThrowAsync().WithMessage("*resolved by hand*"); await _spark.DidNotReceiveWithAnyArgs().WithdrawLeavesAsync(default, default!, default!, default, default); @@ -993,7 +1180,7 @@ public async Task Exit_GoesThroughTheWalletTheSwapWasPaidInto_WhicheverTheNodeUs _spark.WithdrawLeavesAsync(other, Arg.Any>(), Address, 20_000, Arg.Any()) .Returns(new WithdrawLeavesResult("exit-tx", 499_000, 497_000)); - (await Service().ExitAsync(Pending(identity: Ssp, leafIds: "l1"))).Should().Be(SparkExitStep.ExitSent); + (await Exit(Pending(identity: Ssp, leafIds: "l1"))).Should().Be(SparkExitStep.ExitSent); await _spark.DidNotReceive().WithdrawLeavesAsync(W, Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()); @@ -1002,7 +1189,7 @@ await _spark.DidNotReceive().WithdrawLeavesAsync(W, Arg.Any Service().ExitAsync(Pending(lightningFee: null)); + var act = () => Exit(Pending(lightningFee: null)); await act.Should().ThrowAsync().WithMessage("*not paid yet*"); } diff --git a/test/NodeGuard.Tests/Services/Spark/SparkWalletManagementTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkWalletManagementTests.cs index a8567f5b..42fe6cd6 100644 --- a/test/NodeGuard.Tests/Services/Spark/SparkWalletManagementTests.cs +++ b/test/NodeGuard.Tests/Services/Spark/SparkWalletManagementTests.cs @@ -313,13 +313,30 @@ public async Task TheTransactions_AreNewestFirst_WithTheirDirectionTypeAndSwap() transfers.Select(t => t.Id).Should().Equal("exit-1", "stray", "odd", "receive-1"); transfers.Should().BeEquivalentTo(new[] { - new { Id = "exit-1", IsSent = true, Type = "Exit to on-chain", SwapId = (int?)41, Date = DateTimeOffset.UnixEpoch.AddMinutes(5) }, - new { Id = "stray", IsSent = false, Type = "Spark transfer", SwapId = (int?)null, Date = DateTimeOffset.UnixEpoch.AddMinutes(3) }, - new { Id = "odd", IsSent = false, Type = "UtxoSwap", SwapId = (int?)null, Date = DateTimeOffset.UnixEpoch.AddMinutes(2) }, - new { Id = "receive-1", IsSent = false, Type = "Lightning payment in", SwapId = (int?)41, Date = DateTimeOffset.UnixEpoch.AddMinutes(1) } + new { Id = "exit-1", IsSent = true, Type = "Exit to on-chain", SwapIds = new[] { 41 }, Date = DateTimeOffset.UnixEpoch.AddMinutes(5) }, + new { Id = "stray", IsSent = false, Type = "Spark transfer", SwapIds = Array.Empty(), Date = DateTimeOffset.UnixEpoch.AddMinutes(3) }, + new { Id = "odd", IsSent = false, Type = "UtxoSwap", SwapIds = Array.Empty(), Date = DateTimeOffset.UnixEpoch.AddMinutes(2) }, + new { Id = "receive-1", IsSent = false, Type = "Lightning payment in", SwapIds = new[] { 41 }, Date = DateTimeOffset.UnixEpoch.AddMinutes(1) } }, o => o.WithStrictOrdering()); } + [Fact] + public async Task TheTransactions_ABatchsExit_ListsEverySwapItPaid() + { + _spark.Configure().GetTransfersAsync(new SparkWalletRef(5), TransferDirection.Both, DateTimeOffset.UnixEpoch, Arg.Any()) + .Returns(new List { Transfer("exit-1", Identity, "CooperativeExit", minutes: 5, "l1", "l3") }); + _swapOuts.GetSparkSwapsByIdentityAsync(Identity).Returns(new List + { + new() { Id = 41, Provider = SwapProvider.Spark, SparkTransferId = "receive-1", SparkLeafIds = "l1,l2" }, + new() { Id = 42, Provider = SwapProvider.Spark, SparkTransferId = "receive-2", SparkLeafIds = "l3" }, + new() { Id = 43, Provider = SwapProvider.Spark, SparkTransferId = "receive-3", SparkLeafIds = "l4" } + }); + + var transfers = await Service().ListTransfersAsync(5); + + transfers.Should().ContainSingle().Which.SwapIds.Should().Equal(41, 42); + } + [Fact] public async Task TheTransactions_KeepOnlyTheLatest() { From 4689c11c6770ecbf2e7acaf6ea15cc3a5c76b320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:48:57 +0200 Subject: [PATCH 05/10] Nodes grid: min and max swap size columns per provider The grid's single Min/Max Swap Amount toggles, which showed nothing, become one pair per provider (Loop, 40swap, Spark), hidden by default and now rendered; Spark's show the setting's value, marked as the default, when the node has none. A column layout saved before keeps its choices: new columns take their default and gone ones are dropped. --- src/Pages/Nodes.razor | 39 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/src/Pages/Nodes.razor b/src/Pages/Nodes.razor index 53ab4bc6..713a365a 100644 --- a/src/Pages/Nodes.razor +++ b/src/Pages/Nodes.razor @@ -164,6 +164,26 @@ + + @* Each provider's swap sizes (liquidity config) *@ + + @SwapSizeText(context.LoopSwapMinAmountSats) + + + @SwapSizeText(context.LoopSwapMaxAmountSats) + + + @SwapSizeText(context.FortySwapMinAmountSats) + + + @SwapSizeText(context.FortySwapMaxAmountSats) + + + @SparkSwapSizeText(context.SparkSwapMinAmountSats, SparkSettings.SwapMinSats) + + + @SparkSwapSizeText(context.SparkSwapMaxAmountSats, SparkSettings.SwapMaxSats) + @@ -769,6 +789,12 @@ private static decimal SparkLowestMinSwapBtc => SatsToBtc(SparkSettings.LowestSwapMinSats); private static decimal SatsToBtc(long sats) => (decimal)new NBitcoin.Money(sats).ToUnit(NBitcoin.MoneyUnit.BTC); + + private static string SwapSizeText(long sats) => $"{SatsToBtc(sats)} BTC"; + + /// A node's Spark swap size: its own, else the setting's, marked as the default + private static string SparkSwapSizeText(long? sats, long defaultSats) => + sats is { } own ? SwapSizeText(own) : $"{SwapSizeText(defaultSats)} (default)"; private static long BtcToSats(decimal btc) => (long)new NBitcoin.Money(btc, NBitcoin.MoneyUnit.BTC).Satoshi; private decimal _balanceThresholdBtc; private decimal _swapBudgetBtc; @@ -792,8 +818,12 @@ public static readonly ColumnDefault Disabled = new("Disabled"); public static readonly ColumnDefault Autosweep = new("Autosweep"); public static readonly ColumnDefault AutoLiquidityManagement = new("Auto Liquidity Management"); - public static readonly ColumnDefault SwapMinAmount = new("Min Swap Amount",false); - public static readonly ColumnDefault SwapMaxAmount = new("Max Swap Amount",false); + public static readonly ColumnDefault LoopSwapMinAmount = new("Loop Min Swap Amount",false); + public static readonly ColumnDefault LoopSwapMaxAmount = new("Loop Max Swap Amount",false); + public static readonly ColumnDefault FortySwapMinAmount = new("40swap Min Swap Amount",false); + public static readonly ColumnDefault FortySwapMaxAmount = new("40swap Max Swap Amount",false); + public static readonly ColumnDefault SparkSwapMinAmount = new("Spark Min Swap Amount",false); + public static readonly ColumnDefault SparkSwapMaxAmount = new("Spark Max Swap Amount",false); public static readonly ColumnDefault MaxSwapsInFlight = new("Max Swaps In Flight",false); public static readonly ColumnDefault MaxSwapFeeRatio = new("Max Swap Fee Ratio",false); public static readonly ColumnDefault MinimumBalanceThreshold = new("Minimum Balance Threshold",false); @@ -882,7 +912,10 @@ private async Task LoadColumnLayout() { - NodesColumns = await LocalStorageService.LoadStorage(nameof(NodesColumnName), ColumnHelpers.GetColumnsDictionary()); + // A layout saved before columns were added or renamed: new ones take their default, gone ones are dropped + var defaults = ColumnHelpers.GetColumnsDictionary(); + var saved = await LocalStorageService.LoadStorage(nameof(NodesColumnName), defaults); + NodesColumns = defaults.ToDictionary(c => c.Key, c => saved.TryGetValue(c.Key, out var visible) ? visible : c.Value); columnsLoaded = true; StateHasChanged(); } From 2a5089b8dc29f257ee693b78c3f53e2d6ab6308a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:20:58 +0200 Subject: [PATCH 06/10] Consolidate the branch's migrations into one AddPerProviderSwapAmounts, AddSparkPaymentRetries and AddSparkExitBatches become AddPerProviderSwapAmountsAndSparkExitBatches: the same schema and the same hand edits (Loop's pair renamed from the shared one and copied to 40swap's, SparkPaymentAttempt defaulting to 1), and the same model snapshot. A local database that applied the three must be recreated. --- ...5603_AddPerProviderSwapAmounts.Designer.cs | 2015 ---------------- ...4120616_AddSparkPaymentRetries.Designer.cs | 2021 ----------------- .../20261014120616_AddSparkPaymentRetries.cs | 41 - .../20261014121804_AddSparkExitBatches.cs | 29 - ...wapAmountsAndSparkExitBatches.Designer.cs} | 4 +- ...ProviderSwapAmountsAndSparkExitBatches.cs} | 37 +- 6 files changed, 37 insertions(+), 4110 deletions(-) delete mode 100644 src/Migrations/20261014115603_AddPerProviderSwapAmounts.Designer.cs delete mode 100644 src/Migrations/20261014120616_AddSparkPaymentRetries.Designer.cs delete mode 100644 src/Migrations/20261014120616_AddSparkPaymentRetries.cs delete mode 100644 src/Migrations/20261014121804_AddSparkExitBatches.cs rename src/Migrations/{20261014121804_AddSparkExitBatches.Designer.cs => 20261014131911_AddPerProviderSwapAmountsAndSparkExitBatches.Designer.cs} (99%) rename src/Migrations/{20261014115603_AddPerProviderSwapAmounts.cs => 20261014131911_AddPerProviderSwapAmountsAndSparkExitBatches.cs} (67%) diff --git a/src/Migrations/20261014115603_AddPerProviderSwapAmounts.Designer.cs b/src/Migrations/20261014115603_AddPerProviderSwapAmounts.Designer.cs deleted file mode 100644 index 9dfe7ef0..00000000 --- a/src/Migrations/20261014115603_AddPerProviderSwapAmounts.Designer.cs +++ /dev/null @@ -1,2015 +0,0 @@ -// -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("20261014115603_AddPerProviderSwapAmounts")] - partial class AddPerProviderSwapAmounts - { - /// - 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("FortySwapMaxAmountSats") - .HasColumnType("bigint"); - - b.Property("FortySwapMinAmountSats") - .HasColumnType("bigint"); - - b.Property("FortySwapWeight") - .HasColumnType("integer"); - - b.Property("FundsDestinationWalletId") - .HasColumnType("integer"); - - b.Property("IsNodeDisabled") - .HasColumnType("boolean"); - - b.Property("LoopSwapMaxAmountSats") - .HasColumnType("bigint"); - - b.Property("LoopSwapMinAmountSats") - .HasColumnType("bigint"); - - 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("SparkPaymentTimeoutMinutes") - .HasColumnType("integer"); - - b.Property("SparkSwapMaxAmountSats") - .HasColumnType("bigint"); - - b.Property("SparkSwapMinAmountSats") - .HasColumnType("bigint"); - - b.Property("SparkSwapWeight") - .HasColumnType("integer"); - - b.Property("SparkWalletId") - .HasColumnType("integer"); - - b.Property("SwapBudgetRefreshInterval") - .HasColumnType("interval"); - - b.Property("SwapBudgetSats") - .HasColumnType("bigint"); - - b.Property("SwapBudgetStartDatetime") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdateDatetime") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("FundsDestinationWalletId"); - - b.HasIndex("PubKey") - .IsUnique(); - - b.HasIndex("SparkWalletId"); - - 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("DestinationAddress") - .HasColumnType("text"); - - 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("PaymentHash") - .HasColumnType("text"); - - b.Property("PayoutSats") - .HasColumnType("bigint"); - - b.Property("Provider") - .HasColumnType("integer"); - - b.Property("ProviderId") - .HasColumnType("text"); - - b.Property("ReferenceId") - .HasColumnType("text"); - - b.Property("SatsAmount") - .HasColumnType("bigint"); - - b.Property("ServiceFeeSats") - .HasColumnType("bigint"); - - b.Property("SparkIdentity") - .HasColumnType("text"); - - b.Property("SparkLeafIds") - .HasColumnType("text"); - - b.Property("SparkReceivedSats") - .HasColumnType("bigint"); - - b.Property("SparkTransferId") - .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.HasKey("Id"); - - b.HasIndex("DestinationWalletId"); - - b.HasIndex("NodeId"); - - b.HasIndex("ReferenceId") - .IsUnique() - .HasFilter("\"ReferenceId\" IS NOT NULL"); - - 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("Kind") - .HasColumnType("integer"); - - b.Property("MofN") - .HasColumnType("integer"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("ReferenceId") - .HasColumnType("text"); - - b.Property("SparkAccount") - .HasColumnType("integer"); - - b.Property("SparkEncryptedMnemonic") - .HasColumnType("text"); - - b.Property("SparkIdentityPublicKey") - .HasColumnType("text"); - - b.Property("SparkMaxBalanceSats") - .HasColumnType("bigint"); - - 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.HasOne("NodeGuard.Data.Models.Wallet", null) - .WithMany() - .HasForeignKey("SparkWalletId") - .OnDelete(DeleteBehavior.Restrict); - - 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/20261014120616_AddSparkPaymentRetries.Designer.cs b/src/Migrations/20261014120616_AddSparkPaymentRetries.Designer.cs deleted file mode 100644 index 9b1a63c7..00000000 --- a/src/Migrations/20261014120616_AddSparkPaymentRetries.Designer.cs +++ /dev/null @@ -1,2021 +0,0 @@ -// -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("20261014120616_AddSparkPaymentRetries")] - partial class AddSparkPaymentRetries - { - /// - 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("FortySwapMaxAmountSats") - .HasColumnType("bigint"); - - b.Property("FortySwapMinAmountSats") - .HasColumnType("bigint"); - - b.Property("FortySwapWeight") - .HasColumnType("integer"); - - b.Property("FundsDestinationWalletId") - .HasColumnType("integer"); - - b.Property("IsNodeDisabled") - .HasColumnType("boolean"); - - b.Property("LoopSwapMaxAmountSats") - .HasColumnType("bigint"); - - b.Property("LoopSwapMinAmountSats") - .HasColumnType("bigint"); - - 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("SparkPaymentTimeoutMinutes") - .HasColumnType("integer"); - - b.Property("SparkSwapMaxAmountSats") - .HasColumnType("bigint"); - - b.Property("SparkSwapMinAmountSats") - .HasColumnType("bigint"); - - b.Property("SparkSwapWeight") - .HasColumnType("integer"); - - b.Property("SparkWalletId") - .HasColumnType("integer"); - - b.Property("SwapBudgetRefreshInterval") - .HasColumnType("interval"); - - b.Property("SwapBudgetSats") - .HasColumnType("bigint"); - - b.Property("SwapBudgetStartDatetime") - .HasColumnType("timestamp with time zone"); - - b.Property("UpdateDatetime") - .HasColumnType("timestamp with time zone"); - - b.HasKey("Id"); - - b.HasIndex("FundsDestinationWalletId"); - - b.HasIndex("PubKey") - .IsUnique(); - - b.HasIndex("SparkWalletId"); - - 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("DestinationAddress") - .HasColumnType("text"); - - 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("PaymentHash") - .HasColumnType("text"); - - b.Property("PayoutSats") - .HasColumnType("bigint"); - - b.Property("Provider") - .HasColumnType("integer"); - - b.Property("ProviderId") - .HasColumnType("text"); - - b.Property("ReferenceId") - .HasColumnType("text"); - - b.Property("SatsAmount") - .HasColumnType("bigint"); - - b.Property("ServiceFeeSats") - .HasColumnType("bigint"); - - b.Property("SparkIdentity") - .HasColumnType("text"); - - b.Property("SparkInvoiceExpiresAt") - .HasColumnType("timestamp with time zone"); - - b.Property("SparkLeafIds") - .HasColumnType("text"); - - b.Property("SparkPaymentAttempt") - .HasColumnType("integer"); - - b.Property("SparkReceivedSats") - .HasColumnType("bigint"); - - b.Property("SparkTransferId") - .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.HasKey("Id"); - - b.HasIndex("DestinationWalletId"); - - b.HasIndex("NodeId"); - - b.HasIndex("ReferenceId") - .IsUnique() - .HasFilter("\"ReferenceId\" IS NOT NULL"); - - 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("Kind") - .HasColumnType("integer"); - - b.Property("MofN") - .HasColumnType("integer"); - - b.Property("Name") - .IsRequired() - .HasColumnType("text"); - - b.Property("ReferenceId") - .HasColumnType("text"); - - b.Property("SparkAccount") - .HasColumnType("integer"); - - b.Property("SparkEncryptedMnemonic") - .HasColumnType("text"); - - b.Property("SparkIdentityPublicKey") - .HasColumnType("text"); - - b.Property("SparkMaxBalanceSats") - .HasColumnType("bigint"); - - 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.HasOne("NodeGuard.Data.Models.Wallet", null) - .WithMany() - .HasForeignKey("SparkWalletId") - .OnDelete(DeleteBehavior.Restrict); - - 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/20261014120616_AddSparkPaymentRetries.cs b/src/Migrations/20261014120616_AddSparkPaymentRetries.cs deleted file mode 100644 index 72ca46d5..00000000 --- a/src/Migrations/20261014120616_AddSparkPaymentRetries.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace NodeGuard.Migrations -{ - /// - public partial class AddSparkPaymentRetries : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "SparkInvoiceExpiresAt", - table: "SwapOuts", - type: "timestamp with time zone", - nullable: true); - - migrationBuilder.AddColumn( - name: "SparkPaymentAttempt", - table: "SwapOuts", - type: "integer", - nullable: false, - // Every swap recorded so far is on its first attempt - defaultValue: 1); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "SparkInvoiceExpiresAt", - table: "SwapOuts"); - - migrationBuilder.DropColumn( - name: "SparkPaymentAttempt", - table: "SwapOuts"); - } - } -} diff --git a/src/Migrations/20261014121804_AddSparkExitBatches.cs b/src/Migrations/20261014121804_AddSparkExitBatches.cs deleted file mode 100644 index 35b65524..00000000 --- a/src/Migrations/20261014121804_AddSparkExitBatches.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace NodeGuard.Migrations -{ - /// - public partial class AddSparkExitBatches : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn( - name: "SparkExitDatetime", - table: "SwapOuts", - type: "timestamp with time zone", - nullable: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropColumn( - name: "SparkExitDatetime", - table: "SwapOuts"); - } - } -} diff --git a/src/Migrations/20261014121804_AddSparkExitBatches.Designer.cs b/src/Migrations/20261014131911_AddPerProviderSwapAmountsAndSparkExitBatches.Designer.cs similarity index 99% rename from src/Migrations/20261014121804_AddSparkExitBatches.Designer.cs rename to src/Migrations/20261014131911_AddPerProviderSwapAmountsAndSparkExitBatches.Designer.cs index f84a347e..607ef542 100644 --- a/src/Migrations/20261014121804_AddSparkExitBatches.Designer.cs +++ b/src/Migrations/20261014131911_AddPerProviderSwapAmountsAndSparkExitBatches.Designer.cs @@ -14,8 +14,8 @@ namespace NodeGuard.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20261014121804_AddSparkExitBatches")] - partial class AddSparkExitBatches + [Migration("20261014131911_AddPerProviderSwapAmountsAndSparkExitBatches")] + partial class AddPerProviderSwapAmountsAndSparkExitBatches { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) diff --git a/src/Migrations/20261014115603_AddPerProviderSwapAmounts.cs b/src/Migrations/20261014131911_AddPerProviderSwapAmountsAndSparkExitBatches.cs similarity index 67% rename from src/Migrations/20261014115603_AddPerProviderSwapAmounts.cs rename to src/Migrations/20261014131911_AddPerProviderSwapAmountsAndSparkExitBatches.cs index 78130b67..447dee93 100644 --- a/src/Migrations/20261014115603_AddPerProviderSwapAmounts.cs +++ b/src/Migrations/20261014131911_AddPerProviderSwapAmountsAndSparkExitBatches.cs @@ -1,11 +1,12 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using System; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable namespace NodeGuard.Migrations { /// - public partial class AddPerProviderSwapAmounts : Migration + public partial class AddPerProviderSwapAmountsAndSparkExitBatches : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -20,6 +21,26 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "Nodes", newName: "LoopSwapMaxAmountSats"); + migrationBuilder.AddColumn( + name: "SparkExitDatetime", + table: "SwapOuts", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "SparkInvoiceExpiresAt", + table: "SwapOuts", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "SparkPaymentAttempt", + table: "SwapOuts", + type: "integer", + nullable: false, + // Every swap recorded so far is on its first attempt + defaultValue: 1); + migrationBuilder.AddColumn( name: "FortySwapMaxAmountSats", table: "Nodes", @@ -54,6 +75,18 @@ protected override void Up(MigrationBuilder migrationBuilder) /// protected override void Down(MigrationBuilder migrationBuilder) { + migrationBuilder.DropColumn( + name: "SparkExitDatetime", + table: "SwapOuts"); + + migrationBuilder.DropColumn( + name: "SparkInvoiceExpiresAt", + table: "SwapOuts"); + + migrationBuilder.DropColumn( + name: "SparkPaymentAttempt", + table: "SwapOuts"); + migrationBuilder.DropColumn( name: "FortySwapMaxAmountSats", table: "Nodes"); From 074ef147fefe465f8c1111f3721257d43b697b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:26:42 +0200 Subject: [PATCH 07/10] Spark swaps: pay in a scope of its own, which outlives the request that created the swap The background payment ran on the creating request's services, and their scope ends with the request: the database's context factory with it. The payment went through, but saving its outcome threw ObjectDisposedException, leaving it to MonitorSwapsJob, and a failed first attempt was never retried smaller. Task.Run now opens its own scope and pays with a SparkSwapService of it. --- src/Services/Spark/SparkSwapService.cs | 26 +++++++++++++++---- .../Services/Spark/SparkSwapServiceTests.cs | 25 +++++++++++++++++- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/Services/Spark/SparkSwapService.cs b/src/Services/Spark/SparkSwapService.cs index 1eba74b2..4c131c3e 100644 --- a/src/Services/Spark/SparkSwapService.cs +++ b/src/Services/Spark/SparkSwapService.cs @@ -161,12 +161,13 @@ private static DateTimeOffset PaymentDeadlineOf(SwapOut swap) => private readonly INodeRepository _nodeRepository; private readonly ISchedulerFactory _schedulerFactory; private readonly IAuditService _auditService; + private readonly IServiceScopeFactory _scopes; private readonly TimeProvider _time; public SparkSwapService(ILogger logger, SparkSettings settings, ISparkWalletService spark, ILightningService lightningService, ISwapOutRepository swapOutRepository, IWalletRepository walletRepository, INBXplorerService nbXplorerService, INodeRepository nodeRepository, ISchedulerFactory schedulerFactory, - IAuditService auditService, TimeProvider time) + IAuditService auditService, IServiceScopeFactory scopes, TimeProvider time) { _logger = logger; _settings = settings; @@ -178,6 +179,7 @@ public SparkSwapService(ILogger logger, SparkSettings settings _nodeRepository = nodeRepository; _schedulerFactory = schedulerFactory; _auditService = auditService; + _scopes = scopes; _time = time; } @@ -269,11 +271,25 @@ public async Task CreateSwapOutAsync(Node node, SwapOut swapOut Reservation.Release(); } - // Paid in the background, for up to the node's payment timeout: the swap shows as pending meanwhile. Off - // the caller's synchronization context, so the payment doesn't depend on the page that created the swap + // Paid in the background, for up to the node's payment timeout: the swap shows as pending meanwhile. Off the + // caller's synchronization context, and in a scope of its own: the request that created the swap ends long + // before the payment does, and its scope (the database's context factory with it) ends with it var maxFeePercent = request.MaxRoutingFeesPercent ?? DefaultMaxRoutingFeePercent; - _ = Task.Run(() => PayAsync(node, swapOut.Id, paymentRequest, request.Amount, maxFeePercent, request.ChannelsOut, - _settings.PaymentTimeoutFor(node) * 60)); + var (swapId, amount, channelsOut, timeoutSeconds) = + (swapOut.Id, request.Amount, request.ChannelsOut, _settings.PaymentTimeoutFor(node) * 60); + _ = Task.Run(async () => + { + try + { + using var scope = _scopes.CreateScope(); + var service = (SparkSwapService)scope.ServiceProvider.GetRequiredService(); + await service.PayAsync(node, swapId, paymentRequest, amount, maxFeePercent, channelsOut, timeoutSeconds); + } + catch (Exception e) + { + _logger.LogError(e, "Spark swap {SwapId}: its payment could not be started; MonitorSwapsJob takes over", swapId); + } + }); _logger.LogInformation("Spark swap {SwapId}: {Node} is paying {Amount} sats into Spark", swapOut.Id, node.Name, request.Amount); return new SwapOutCreation(Response(swapOut), true, null); diff --git a/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs index 494f0366..85a9d2ad 100644 --- a/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs +++ b/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs @@ -19,6 +19,7 @@ using FluentAssertions; using Lnrpc; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using NBitcoin; using NBXplorer.Models; @@ -59,6 +60,8 @@ public class SparkSwapServiceTests private readonly ISchedulerFactory _schedulerFactory = Substitute.For(); private readonly IScheduler _scheduler = Substitute.For(); private readonly IAuditService _audit = Substitute.For(); + private readonly IServiceScopeFactory _scopes = Substitute.For(); + private readonly IServiceScope _paymentScope = Substitute.For(); private readonly ManualTime _time = new(); private readonly Node _node = new() { Id = 1, Name = "alice", PubKey = "02aa", Endpoint = "alice:10009", SparkWalletId = 5 }; private readonly Wallet _wallet; @@ -86,10 +89,16 @@ public SparkSwapServiceTests() Payouts(); _nodes.GetById(1, Arg.Any()).Returns(_node); _schedulerFactory.GetScheduler(Arg.Any()).Returns(_scheduler); + + // The background payment runs in a scope of its own, with a SparkSwapService of that scope + var provider = Substitute.For(); + provider.GetService(typeof(ISparkSwapService)).Returns(_ => Service()); + _paymentScope.ServiceProvider.Returns(provider); + _scopes.CreateScope().Returns(_paymentScope); } private SparkSwapService Service() => new(NullLogger.Instance, Settings, _spark, _lightning, _swapOuts, - _wallets, _nbXplorer, _nodes, _schedulerFactory, _audit, _time); + _wallets, _nbXplorer, _nodes, _schedulerFactory, _audit, _scopes, _time); // 500_000 sats at 1%: a fee limit of 5_000_000 msat private Task Pay(int swapId = 7) => Service().PayAsync(_node, swapId, Invoice, 500_000, 1m, null, 600); @@ -262,6 +271,20 @@ public async Task Create_APaidSwapWaitingInTheWallet_IsNotInFlight() saved.Should().BeTrue(); } + [Fact] + public async Task Create_PaysInAScopeOfItsOwn_WhichOutlivesTheCaller() + { + InvoiceIssued(); + var started = PaymentStarted(); + + await Service().CreateSwapOutAsync(_node, Template(), Request()); + await started; + + // The caller's scope may end as soon as the swap is created: the payment saves its outcome through its own + _scopes.Received(1).CreateScope(); + _paymentScope.DidNotReceive().Dispose(); + } + [Fact] public async Task Create_PaysWithTheNodesOwnPaymentTimeout() { From 07693bb68fd61add196a6a33968ccf6d51ced227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:53:01 +0200 Subject: [PATCH 08/10] Spark swaps: no payment retries Undo the smaller retries of a failed Spark swap payment: a definitive failure fails the swap again, as before. SparkSwapPaymentJob, the retry audit action and the swaps' SparkPaymentAttempt and SparkInvoiceExpiresAt columns go, and the single migration is regenerated without them. The payment still runs in a scope of its own. --- docs/spark-swaps.md | 8 +- src/Data/Models/AuditLog.cs | 5 +- src/Data/Models/Swaps.cs | 11 -- src/Jobs/SparkSwapPaymentJob.cs | 101 ---------- ...wapAmountsAndSparkExitBatches.Designer.cs} | 8 +- ...ProviderSwapAmountsAndSparkExitBatches.cs} | 22 --- .../ApplicationDbContextModelSnapshot.cs | 6 - src/Services/Spark/SparkSwapService.cs | 160 ++-------------- src/Shared/NewSwapModal.razor | 13 -- .../Services/Spark/SparkSwapServiceTests.cs | 180 +----------------- 10 files changed, 20 insertions(+), 494 deletions(-) delete mode 100644 src/Jobs/SparkSwapPaymentJob.cs rename src/Migrations/{20261014131911_AddPerProviderSwapAmountsAndSparkExitBatches.Designer.cs => 20261016135127_AddPerProviderSwapAmountsAndSparkExitBatches.Designer.cs} (99%) rename src/Migrations/{20261014131911_AddPerProviderSwapAmountsAndSparkExitBatches.cs => 20261016135127_AddPerProviderSwapAmountsAndSparkExitBatches.cs} (80%) diff --git a/docs/spark-swaps.md b/docs/spark-swaps.md index a4945c1a..9641e22d 100644 --- a/docs/spark-swaps.md +++ b/docs/spark-swaps.md @@ -15,13 +15,9 @@ The Spark client is the [NSpark](https://github.com/orklabs/nspark) .NET SDK, fr 2. A swap that would bring the wallet over its max balance, counting what its swaps not paid yet will still bring in, is refused. 3. The Spark wallet issues an invoice through the SSP. An invoice without a receive request is refused before anything is paid: the swap's funds are found only through the transfer the SSP reports for it. 4. The swap is recorded before anything is paid: its payment hash, destination wallet and the Spark identity that will receive it. No address is reserved yet: the swap needs one only when it exits. -5. The node pays the invoice in the background: creation returns as soon as the swap is recorded, and the swap shows as pending on the Swaps page meanwhile. LND keeps trying routes for up to the node's Spark payment timeout (its liquidity settings on the Nodes page, else `SPARK_PAYMENT_TIMEOUT_MINUTES`), splitting the payment into up to `SEND_PAYMENT_MAX_PARTS` parts. The payment is not cancellable. Once paid, its routing fee is saved. An unknown outcome, such as a broken payment stream or NodeGuard restarting mid-payment, is left to the monitor, which finds the payment by its hash. -6. **Retries.** A payment that fails for no route, a timeout or too little outbound liquidity is retried with a smaller amount, like a rebalance: up to `SEND_PAYMENT_MAX_ATTEMPTS` attempts in all, each `SEND_PAYMENT_AMOUNT_BACKOFF_RATIO` of the previous one, never below the node's Spark min swap, after a delay of `SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS` growing by `SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER` (60 s, then 120 s by default). - - A Spark invoice has a fixed amount (the SSP issues none without one), so each retry gets a fresh, smaller invoice. It is saved on the swap before the delay, so the swap never points at a failed payment; the swap's amount is the current attempt's. - - The retry is a Quartz job in the persistent store, so it survives a restart. It pays only if the swap still waits for that invoice and the node has no record of its payment yet. - - Any other failure, or one with no retry left, fails the swap. +5. The node pays the invoice in the background: creation returns as soon as the swap is recorded, and the swap shows as pending on the Swaps page meanwhile. LND keeps trying routes for up to the node's Spark payment timeout (its liquidity settings on the Nodes page, else `SPARK_PAYMENT_TIMEOUT_MINUTES`), splitting the payment into up to `SEND_PAYMENT_MAX_PARTS` parts. The payment is not cancellable. Once paid, its routing fee is saved; a definitive failure fails the swap. An unknown outcome, such as a broken payment stream or NodeGuard restarting mid-payment, is left to the monitor, which finds the payment by its hash. -**Payment.** `MonitorSwapsJob` runs every 10 minutes, or every minute in a dev environment. It looks the payment up on the node by its hash. The swap fails if the payment failed, or if the node never saw it and the current invoice expired 10 minutes ago. Once the payment has settled, the swap is `SparkSwapExitJob`'s. +**Payment.** `MonitorSwapsJob` runs every 10 minutes, or every minute in a dev environment. It looks the payment up on the node by its hash. The swap fails if the payment failed, or if the node never saw it and the invoice has expired. Once the payment has settled, the swap is `SparkSwapExitJob`'s. **Exit.** `SparkSwapExitJob` runs every `SPARK_EXIT_INTERVAL_MINUTES` (10 by default, 1 in a dev environment). Paid swaps exit from the Spark wallet they were paid into (found by its identity, whichever wallet the node uses now), in saved steps: paid, then leaves attributed, then exit sent, then payout confirmed. 1. **Leaves attributed.** A Spark balance is made of leaves of fixed sizes, and the payment arrives as one inbound transfer whose leaves add up to what it brought in. The job claims it and records on the swap the transfer (the one the SSP reports for the swap's receive request), its leaves and their total. A transfer is given to one swap only, and a swap only ever takes its own: while the SSP reports none, the swap waits, and nothing is matched by amount. Only a claimed transfer is attributed: one whose claim failed is claimed again on the next run. A transfer that can never be claimed leaves the swap waiting until the overdue alert. diff --git a/src/Data/Models/AuditLog.cs b/src/Data/Models/AuditLog.cs index 49c904ce..7230ed80 100644 --- a/src/Data/Models/AuditLog.cs +++ b/src/Data/Models/AuditLog.cs @@ -165,10 +165,7 @@ public enum AuditActionType // Spark transit wallet alerts SparkBalanceStuck, - SparkExitOverdue, - - // A Spark swap's failed payment, retried with a smaller amount - SwapOutPaymentRetryScheduled + SparkExitOverdue } /// diff --git a/src/Data/Models/Swaps.cs b/src/Data/Models/Swaps.cs index 17469db0..6dd19d10 100644 --- a/src/Data/Models/Swaps.cs +++ b/src/Data/Models/Swaps.cs @@ -171,17 +171,6 @@ public class SwapOut : Entity /// public string? SparkIdentity { get; set; } - /// - /// Spark swaps only: which attempt the Lightning payment is on. A retry pays a fresh, smaller invoice - /// (, and are the current one's) - /// - public int SparkPaymentAttempt { get; set; } = 1; - - /// - /// Spark swaps only: when the current invoice expires. Its payment can't arrive after that - /// - public DateTimeOffset? SparkInvoiceExpiresAt { get; set; } - /// /// Spark swaps only: the inbound Spark transfer that brought the swap's Lightning payment in. A transfer /// is given to one swap only diff --git a/src/Jobs/SparkSwapPaymentJob.cs b/src/Jobs/SparkSwapPaymentJob.cs deleted file mode 100644 index 5235844f..00000000 --- a/src/Jobs/SparkSwapPaymentJob.cs +++ /dev/null @@ -1,101 +0,0 @@ -/* - * NodeGuard - * Copyright (C) 2023 Elenpay - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - * - */ - -using System.Globalization; -using NodeGuard.Services.Spark; -using Quartz; - -namespace NodeGuard.Jobs; - -/// -/// One-shot Quartz job that pays a retry of a Spark swap's payment once its backoff delay is over. Its invoice was -/// issued and saved on the swap when the previous attempt failed; the job carries what paying it needs. Recovered -/// after a restart: never pays twice. -/// -public class SparkSwapPaymentJob : IJob -{ - private const string SwapIdKey = "swapId"; - private const string PaymentHashKey = "paymentHash"; - private const string PaymentRequestKey = "paymentRequest"; - private const string MaxFeePercentKey = "maxFeePercent"; - private const string ChannelsOutKey = "channelsOut"; - private const string TimeoutSecondsKey = "timeoutSeconds"; - - private readonly ILogger _logger; - private readonly ISparkSwapService _sparkSwaps; - - public SparkSwapPaymentJob(ILogger logger, ISparkSwapService sparkSwaps) - { - _logger = logger; - _sparkSwaps = sparkSwaps; - } - - /// Schedules the payment of attempt of a swap's payment at . - public static async Task ScheduleAsync(ISchedulerFactory schedulerFactory, DateTimeOffset fireAt, int swapId, int attempt, - string paymentHash, string paymentRequest, decimal maxFeePercent, ulong[]? channelsOut, int timeoutSeconds) - { - var data = new JobDataMap - { - { SwapIdKey, swapId }, - { PaymentHashKey, paymentHash }, - { PaymentRequestKey, paymentRequest }, - { MaxFeePercentKey, maxFeePercent.ToString(CultureInfo.InvariantCulture) }, - { ChannelsOutKey, string.Join(',', channelsOut ?? []) }, - { TimeoutSecondsKey, timeoutSeconds } - }; - - var job = JobBuilder.Create() - .WithIdentity($"{nameof(SparkSwapPaymentJob)}-{swapId}-a{attempt}") - .UsingJobData(data) - .RequestRecovery() - .Build(); - - var trigger = TriggerBuilder.Create() - .WithIdentity($"{nameof(SparkSwapPaymentJob)}Trigger-{swapId}-a{attempt}") - .StartAt(fireAt) - .Build(); - - var scheduler = await schedulerFactory.GetScheduler(); - await scheduler.ScheduleJob(job, trigger); - } - - public async Task Execute(IJobExecutionContext context) - { - var data = context.MergedJobDataMap; - var swapId = data.GetIntValue(SwapIdKey); - _logger.LogInformation("Paying the scheduled retry of Spark swap {SwapId}", swapId); - - try - { - var channels = data.GetString(ChannelsOutKey); - await _sparkSwaps.PayScheduledAsync(swapId, - data.GetString(PaymentHashKey)!, - data.GetString(PaymentRequestKey)!, - decimal.Parse(data.GetString(MaxFeePercentKey)!, CultureInfo.InvariantCulture), - string.IsNullOrEmpty(channels) ? null : channels.Split(',').Select(ulong.Parse).ToArray(), - data.GetIntValue(TimeoutSecondsKey), - context.CancellationToken); - } - catch (Exception e) - { - _logger.LogError(e, "Error paying the scheduled retry of Spark swap {SwapId}", swapId); - throw new JobExecutionException(e, refireImmediately: false); - } - } -} diff --git a/src/Migrations/20261014131911_AddPerProviderSwapAmountsAndSparkExitBatches.Designer.cs b/src/Migrations/20261016135127_AddPerProviderSwapAmountsAndSparkExitBatches.Designer.cs similarity index 99% rename from src/Migrations/20261014131911_AddPerProviderSwapAmountsAndSparkExitBatches.Designer.cs rename to src/Migrations/20261016135127_AddPerProviderSwapAmountsAndSparkExitBatches.Designer.cs index 607ef542..abec2403 100644 --- a/src/Migrations/20261014131911_AddPerProviderSwapAmountsAndSparkExitBatches.Designer.cs +++ b/src/Migrations/20261016135127_AddPerProviderSwapAmountsAndSparkExitBatches.Designer.cs @@ -14,7 +14,7 @@ namespace NodeGuard.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20261014131911_AddPerProviderSwapAmountsAndSparkExitBatches")] + [Migration("20261016135127_AddPerProviderSwapAmountsAndSparkExitBatches")] partial class AddPerProviderSwapAmountsAndSparkExitBatches { /// @@ -1270,15 +1270,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("SparkIdentity") .HasColumnType("text"); - b.Property("SparkInvoiceExpiresAt") - .HasColumnType("timestamp with time zone"); - b.Property("SparkLeafIds") .HasColumnType("text"); - b.Property("SparkPaymentAttempt") - .HasColumnType("integer"); - b.Property("SparkReceivedSats") .HasColumnType("bigint"); diff --git a/src/Migrations/20261014131911_AddPerProviderSwapAmountsAndSparkExitBatches.cs b/src/Migrations/20261016135127_AddPerProviderSwapAmountsAndSparkExitBatches.cs similarity index 80% rename from src/Migrations/20261014131911_AddPerProviderSwapAmountsAndSparkExitBatches.cs rename to src/Migrations/20261016135127_AddPerProviderSwapAmountsAndSparkExitBatches.cs index 447dee93..2b26053a 100644 --- a/src/Migrations/20261014131911_AddPerProviderSwapAmountsAndSparkExitBatches.cs +++ b/src/Migrations/20261016135127_AddPerProviderSwapAmountsAndSparkExitBatches.cs @@ -27,20 +27,6 @@ protected override void Up(MigrationBuilder migrationBuilder) type: "timestamp with time zone", nullable: true); - migrationBuilder.AddColumn( - name: "SparkInvoiceExpiresAt", - table: "SwapOuts", - type: "timestamp with time zone", - nullable: true); - - migrationBuilder.AddColumn( - name: "SparkPaymentAttempt", - table: "SwapOuts", - type: "integer", - nullable: false, - // Every swap recorded so far is on its first attempt - defaultValue: 1); - migrationBuilder.AddColumn( name: "FortySwapMaxAmountSats", table: "Nodes", @@ -79,14 +65,6 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "SparkExitDatetime", table: "SwapOuts"); - migrationBuilder.DropColumn( - name: "SparkInvoiceExpiresAt", - table: "SwapOuts"); - - migrationBuilder.DropColumn( - name: "SparkPaymentAttempt", - table: "SwapOuts"); - migrationBuilder.DropColumn( name: "FortySwapMaxAmountSats", table: "Nodes"); diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index 3d8c1a11..ab4ed356 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1267,15 +1267,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("SparkIdentity") .HasColumnType("text"); - b.Property("SparkInvoiceExpiresAt") - .HasColumnType("timestamp with time zone"); - b.Property("SparkLeafIds") .HasColumnType("text"); - b.Property("SparkPaymentAttempt") - .HasColumnType("integer"); - b.Property("SparkReceivedSats") .HasColumnType("bigint"); diff --git a/src/Services/Spark/SparkSwapService.cs b/src/Services/Spark/SparkSwapService.cs index 4c131c3e..49f50853 100644 --- a/src/Services/Spark/SparkSwapService.cs +++ b/src/Services/Spark/SparkSwapService.cs @@ -23,12 +23,10 @@ using NBXplorer.Models; using NodeGuard.Data.Models; using NodeGuard.Data.Repositories.Interfaces; -using NodeGuard.Jobs; using NSpark; using NSpark.Exceptions; using NSpark.Models; using NSpark.Services; -using Quartz; namespace NodeGuard.Services.Spark; @@ -76,14 +74,6 @@ public interface ISparkSwapService /// Task CreateSwapOutAsync(Node node, SwapOut swapOut, SwapOutRequest request, CancellationToken ct = default); - /// - /// Pays a retry of a swap's payment, scheduled after its previous attempt failed (SparkSwapPaymentJob): only - /// while the swap still waits for that invoice () and the node has no record of - /// its payment yet, so a job run again after a restart never pays twice. - /// - Task PayScheduledAsync(int swapId, string paymentHash, string paymentRequest, decimal maxFeePercent, - ulong[]? channelsOut, int timeoutSeconds, CancellationToken ct = default); - /// /// Follows a pending Spark swap's Lightning payment and returns its state. Once the payment settled, its fee /// is saved on and the swap is SparkSwapExitJob's to exit. @@ -135,12 +125,8 @@ public sealed class SparkSwapService : ISparkSwapService private const string ExitTransferType = "CooperativeExit"; private static readonly TimeSpan PaymentLookupTimeout = TimeSpan.FromSeconds(30); - /// - /// When nothing has arrived by then, the swap's current invoice has expired and its payment never will: 10 - /// minutes after it expires (30 minutes after creation for swaps recorded without its expiry). - /// - private static DateTimeOffset PaymentDeadlineOf(SwapOut swap) => - (swap.SparkInvoiceExpiresAt ?? swap.CreationDatetime + TimeSpan.FromSeconds(InvoiceExpirySeconds)) + TimeSpan.FromMinutes(10); + /// When nothing has arrived by then, the invoice has expired and the payment never will. + private static readonly TimeSpan PaymentDeadline = TimeSpan.FromSeconds(InvoiceExpirySeconds) + TimeSpan.FromMinutes(10); /// Transfers are looked for from this long before the swap was created, for clock skew. private static readonly TimeSpan TransferLookback = TimeSpan.FromMinutes(5); @@ -159,15 +145,12 @@ private static DateTimeOffset PaymentDeadlineOf(SwapOut swap) => private readonly IWalletRepository _walletRepository; private readonly INBXplorerService _nbXplorerService; private readonly INodeRepository _nodeRepository; - private readonly ISchedulerFactory _schedulerFactory; - private readonly IAuditService _auditService; private readonly IServiceScopeFactory _scopes; private readonly TimeProvider _time; public SparkSwapService(ILogger logger, SparkSettings settings, ISparkWalletService spark, ILightningService lightningService, ISwapOutRepository swapOutRepository, IWalletRepository walletRepository, - INBXplorerService nbXplorerService, INodeRepository nodeRepository, ISchedulerFactory schedulerFactory, - IAuditService auditService, IServiceScopeFactory scopes, TimeProvider time) + INBXplorerService nbXplorerService, INodeRepository nodeRepository, IServiceScopeFactory scopes, TimeProvider time) { _logger = logger; _settings = settings; @@ -177,8 +160,6 @@ public SparkSwapService(ILogger logger, SparkSettings settings _walletRepository = walletRepository; _nbXplorerService = nbXplorerService; _nodeRepository = nodeRepository; - _schedulerFactory = schedulerFactory; - _auditService = auditService; _scopes = scopes; _time = time; } @@ -255,8 +236,6 @@ public async Task CreateSwapOutAsync(Node node, SwapOut swapOut swapOut.Status = SwapOutStatus.Pending; swapOut.SatsAmount = request.Amount; swapOut.PaymentHash = invoice.PaymentHash; - swapOut.SparkPaymentAttempt = 1; - swapOut.SparkInvoiceExpiresAt = invoice.ExpiresAt; swapOut.SparkIdentity = status.IdentityPublicKey; // Recorded before paying: from here on the monitor can resolve the swap whatever happens next @@ -297,10 +276,9 @@ public async Task CreateSwapOutAsync(Node node, SwapOut swapOut /// /// Pays a recorded swap's invoice of , with a fee limit of - /// of it, and saves the outcome: the routing fee once paid; once it failed for - /// want of a route or liquidity, a smaller retry (), else the failure. An unknown - /// outcome, or an error here, is left to MonitorSwapsJob, which finds the payment by its hash. Not cancellable: - /// once the payment starts it is followed to its end. + /// of it, and saves the outcome: the routing fee once paid, the failure once it + /// definitely failed. An unknown outcome, or an error here, is left to MonitorSwapsJob, which finds the payment by + /// its hash. Not cancellable: once the payment starts it is followed to its end. /// internal async Task PayAsync(Node node, int swapId, string paymentRequest, long amountSats, decimal maxFeePercent, ulong[]? channelsOut, int timeoutSeconds) @@ -309,7 +287,7 @@ internal async Task PayAsync(Node node, int swapId, string paymentRequest, long { var result = await _lightningService.SendPaymentV2Async(node, paymentRequest, 0, FeeLimitMsat(amountSats, maxFeePercent), channelsOut, null, timeoutSeconds, CancellationToken.None); - await SavePaymentAsync(node, swapId, result, maxFeePercent, channelsOut, timeoutSeconds); + await SavePaymentAsync(node, swapId, result); } catch (Exception e) { @@ -319,8 +297,7 @@ internal async Task PayAsync(Node node, int swapId, string paymentRequest, long private static long FeeLimitMsat(long amountSats, decimal maxFeePercent) => (long)(amountSats * 1000m * maxFeePercent / 100m); - private async Task SavePaymentAsync(Node node, int swapId, Payment result, decimal maxFeePercent, ulong[]? channelsOut, - int timeoutSeconds) + private async Task SavePaymentAsync(Node node, int swapId, Payment result) { // Read again once the payment ended: the monitor may have resolved the swap meanwhile if (await _swapOutRepository.GetById(swapId) is not { Status: SwapOutStatus.Pending, LightningFeeSats: null } swap) @@ -339,18 +316,10 @@ private async Task SavePaymentAsync(Node node, int swapId, Payment result, decim else if (result.Status == Payment.Types.PaymentStatus.Failed && result.FailureReason != PaymentFailureReason.FailureReasonError) { - if (RetriedFailures.Contains(result.FailureReason) && - await RetrySmallerAsync(node, swap, result.FailureReason, maxFeePercent, channelsOut, timeoutSeconds)) - { - return; - } - swap.Status = SwapOutStatus.Failed; - swap.ErrorDetails = swap.SparkPaymentAttempt > 1 - ? $"Lightning payment failed: {result.FailureReason} (attempt {swap.SparkPaymentAttempt}, {swap.SatsAmount} sats)" - : $"Lightning payment failed: {result.FailureReason}"; - _logger.LogWarning("Spark swap {SwapId}: the payment from {Node} failed on attempt {Attempt}: {Reason}", - swap.Id, node.Name, swap.SparkPaymentAttempt, result.FailureReason); + swap.ErrorDetails = $"Lightning payment failed: {result.FailureReason}"; + _logger.LogWarning("Spark swap {SwapId}: the payment from {Node} failed: {Reason}", + swap.Id, node.Name, result.FailureReason); } else { @@ -363,111 +332,6 @@ await RetrySmallerAsync(node, swap, result.FailureReason, maxFeePercent, channel Save(swap); } - /// Failures a smaller amount may get past: no route, a timeout, or too little outbound liquidity. - private static readonly HashSet RetriedFailures = - [ - PaymentFailureReason.FailureReasonNoRoute, - PaymentFailureReason.FailureReasonTimeout, - PaymentFailureReason.FailureReasonInsufficientBalance - ]; - - /// - /// Retries a failed payment with a smaller amount (SEND_PAYMENT_AMOUNT_BACKOFF_RATIO, down to the node's Spark - /// min swap, up to SEND_PAYMENT_MAX_ATTEMPTS attempts): issues a fresh invoice for it right away and saves it on - /// the swap, so the swap never points at a failed payment and the monitor leaves it be, then schedules its - /// payment after the backoff delay (SparkSwapPaymentJob). False when no retry is left, or the new invoice could - /// not be issued or saved. - /// - private async Task RetrySmallerAsync(Node node, SwapOut swap, PaymentFailureReason reason, decimal maxFeePercent, - ulong[]? channelsOut, int timeoutSeconds) - { - var attempt = swap.SparkPaymentAttempt + 1; - var amount = (long)Math.Floor(swap.SatsAmount * (decimal)Constants.SEND_PAYMENT_AMOUNT_BACKOFF_RATIO); - var minSwap = SwapAmountLimits.For(node, SwapProvider.Spark, _settings).MinSats; - if (attempt > Constants.SEND_PAYMENT_MAX_ATTEMPTS || amount < minSwap || amount >= swap.SatsAmount) - { - _logger.LogInformation("Spark swap {SwapId}: no retry left after attempt {Attempt} ({Amount} sats; next {Next}, min {Min})", - swap.Id, swap.SparkPaymentAttempt, swap.SatsAmount, amount, minSwap); - return false; - } - - LightningInvoice invoice; - try - { - var wallet = await SwapWalletAsync(swap, CancellationToken.None); - invoice = await _spark.CreateInvoiceAsync(wallet.Ref, amount, $"NodeGuard swap-out from {node.Name}", - InvoiceExpirySeconds, CancellationToken.None); - } - catch (Exception e) - { - _logger.LogWarning(e, "Spark swap {SwapId}: could not issue the invoice for its retry", swap.Id); - return false; - } - - if (string.IsNullOrEmpty(invoice.RequestId)) - { - _logger.LogWarning("Spark swap {SwapId}: the SSP issued the retry's invoice without a receive request", swap.Id); - return false; - } - - var previous = (swap.ProviderId, swap.PaymentHash, swap.SatsAmount, swap.SparkPaymentAttempt, swap.SparkInvoiceExpiresAt); - swap.ProviderId = invoice.RequestId; - swap.PaymentHash = invoice.PaymentHash; - swap.SatsAmount = amount; - swap.SparkPaymentAttempt = attempt; - swap.SparkInvoiceExpiresAt = invoice.ExpiresAt; - if (!Save(swap)) - { - (swap.ProviderId, swap.PaymentHash, swap.SatsAmount, swap.SparkPaymentAttempt, swap.SparkInvoiceExpiresAt) = previous; - return false; - } - - var delaySeconds = Constants.SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS * - Math.Pow(Constants.SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER, attempt - 2); - var fireAt = _time.GetUtcNow().AddSeconds(delaySeconds); - await SparkSwapPaymentJob.ScheduleAsync(_schedulerFactory, fireAt, swap.Id, attempt, invoice.PaymentHash, - invoice.PaymentRequest, maxFeePercent, channelsOut, timeoutSeconds); - - _logger.LogInformation("Spark swap {SwapId}: the payment from {Node} failed ({Reason}); attempt {Attempt} pays {Amount} sats at {FireAt:O}", - swap.Id, node.Name, reason, attempt, amount, fireAt); - await _auditService.LogSystemAsync(AuditActionType.SwapOutPaymentRetryScheduled, AuditEventType.Attempt, - AuditObjectType.SwapOut, swap.Id.ToString(), - new { Reason = reason.ToString(), Attempt = attempt, AmountSats = amount, previous.SatsAmount, FireAt = fireAt }); - return true; - } - - public async Task PayScheduledAsync(int swapId, string paymentHash, string paymentRequest, decimal maxFeePercent, - ulong[]? channelsOut, int timeoutSeconds, CancellationToken ct = default) - { - if (await _swapOutRepository.GetById(swapId) is not { Status: SwapOutStatus.Pending, LightningFeeSats: null } swap || - swap.PaymentHash != paymentHash) - { - _logger.LogInformation("Spark swap {SwapId}: no longer waits for the payment of {PaymentHash}, which is not sent", - swapId, paymentHash); - return; - } - - if (swap.NodeId is not { } nodeId || await _nodeRepository.GetById(nodeId) is not { } node) - { - _logger.LogError("Spark swap {SwapId}: its node {NodeId} is gone, so its retry is not paid", swapId, swap.NodeId); - return; - } - - // Run again after a restart: what the node already knows of is followed by the monitor instead - using (var lookup = CancellationTokenSource.CreateLinkedTokenSource(ct)) - { - lookup.CancelAfter(PaymentLookupTimeout); - if (await _lightningService.TrackPaymentV2Async(node, Convert.FromHexString(paymentHash), lookup.Token) is { } known) - { - _logger.LogInformation("Spark swap {SwapId}: the node already has the payment of attempt {Attempt} ({Status})", - swapId, swap.SparkPaymentAttempt, known.Status); - return; - } - } - - await PayAsync(node, swapId, paymentRequest, swap.SatsAmount, maxFeePercent, channelsOut, timeoutSeconds); - } - public async Task AdvanceSwapAsync(Node node, SwapOut swap, CancellationToken ct = default) { if (string.IsNullOrEmpty(swap.PaymentHash)) @@ -730,7 +594,7 @@ private async Task ExitBatchAsync(IReadOnlyList batch, b case Payment.Types.PaymentStatus.Failed: return $"Lightning payment failed: {payment.FailureReason}"; - case null when _time.GetUtcNow() > PaymentDeadlineOf(swap): + case null when _time.GetUtcNow() - swap.CreationDatetime > PaymentDeadline: // The node has no record of the payment and the invoice expired: no payment will arrive. The SSP's // view is only a hint (not every SSP serves it), so a failed lookup does not block this var receive = await ReceiveStatusAsync(swap, sparkWallet); diff --git a/src/Shared/NewSwapModal.razor b/src/Shared/NewSwapModal.razor index 26ab3c29..1f2d0515 100644 --- a/src/Shared/NewSwapModal.razor +++ b/src/Shared/NewSwapModal.razor @@ -536,7 +536,6 @@ $"The node starts paying its Spark wallet ({SparkIdentityOf(_selectedNode!)}) as you confirm, and LND keeps " + $"trying routes for up to {SparkSettings.PaymentTimeoutFor(_selectedNode!)} minutes (the node's Spark payment timeout); " + "the swap shows as pending on the Swaps page meanwhile. " + - SparkRetrySummary(_selectedNode!) + "Once paid, the swap waits in the Spark wallet until the wallet is full (no room left for another swap under its max balance), " + "then exits with the wallet's other paid swaps in one on-chain transaction to the destination wallet, sharing its fee, " + $"at exit speed {SparkSettings.ExitSpeedSummary}. " + @@ -627,18 +626,6 @@ private string? SparkIdentityOf(Node node) => SparkSettings.WalletFor(node) is { } wallet ? SparkWallet.GetStatus(wallet).IdentityPublicKey : null; - /// How a Spark swap's failed payment is retried, as the confirmation says it - private string SparkRetrySummary(Node node) - { - var retries = Constants.SEND_PAYMENT_MAX_ATTEMPTS - 1; - if (retries < 1) return ""; - - var smallerPercent = Math.Round((1 - (decimal)Constants.SEND_PAYMENT_AMOUNT_BACKOFF_RATIO) * 100); - var minSwap = new Money(SwapAmountLimits.For(node, SwapProvider.Spark, SparkSettings).MinSats); - return $"If no route or liquidity can carry it, the payment is retried up to {retries} times, each {smallerPercent}% " + - $"smaller, down to the node's Spark min swap ({minSwap.ToUnit(MoneyUnit.BTC)} BTC). "; - } - private IEnumerable AvailableProviders => Enum.GetValues().Where(p => p != SwapProvider.Spark || SparkSettings.Enabled); diff --git a/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs index 85a9d2ad..8678d51a 100644 --- a/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs +++ b/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs @@ -32,7 +32,6 @@ using NSpark.Services; using NSubstitute; using NSubstitute.ExceptionExtensions; -using Quartz; namespace NodeGuard.Services.Spark; @@ -57,9 +56,6 @@ public class SparkSwapServiceTests private readonly IWalletRepository _wallets = Substitute.For(); private readonly INBXplorerService _nbXplorer = Substitute.For(); private readonly INodeRepository _nodes = Substitute.For(); - private readonly ISchedulerFactory _schedulerFactory = Substitute.For(); - private readonly IScheduler _scheduler = Substitute.For(); - private readonly IAuditService _audit = Substitute.For(); private readonly IServiceScopeFactory _scopes = Substitute.For(); private readonly IServiceScope _paymentScope = Substitute.For(); private readonly ManualTime _time = new(); @@ -88,7 +84,6 @@ public SparkSwapServiceTests() _wallets.GetById(3).Returns(_wallet); Payouts(); _nodes.GetById(1, Arg.Any()).Returns(_node); - _schedulerFactory.GetScheduler(Arg.Any()).Returns(_scheduler); // The background payment runs in a scope of its own, with a SparkSwapService of that scope var provider = Substitute.For(); @@ -98,19 +93,11 @@ public SparkSwapServiceTests() } private SparkSwapService Service() => new(NullLogger.Instance, Settings, _spark, _lightning, _swapOuts, - _wallets, _nbXplorer, _nodes, _schedulerFactory, _audit, _scopes, _time); + _wallets, _nbXplorer, _nodes, _scopes, _time); // 500_000 sats at 1%: a fee limit of 5_000_000 msat private Task Pay(int swapId = 7) => Service().PayAsync(_node, swapId, Invoice, 500_000, 1m, null, 600); - private const string RetryInvoice = "lnbcrt4m1retry"; - private const string RetryHash = "0202020202020202020202020202020202020202020202020202020202020202"; - - /// The SSP issues the retry's smaller invoice. - private void RetryInvoiceIssued(long amountSats) => - _spark.CreateInvoiceAsync(W, amountSats, Arg.Any(), SparkSwapService.InvoiceExpirySeconds, Arg.Any()) - .Returns(new LightningInvoice(RetryInvoice, RetryHash, amountSats, DateTimeOffset.UnixEpoch.AddMinutes(90), "req-2")); - /// /// Completes with the payment's timeout once the node starts paying in the background. The payment itself never /// ends, so a creation that returns did not wait for it. @@ -328,89 +315,8 @@ public async Task Pay_ASucceededPayment_SavesItsRoutingFee_AndIsNotCancellable() } [Fact] - public async Task Pay_WhenThePaymentFailsForAReasonASmallerAmountWontFix_FailsTheSwap() - { - var swap = Pending(lightningFee: null); - _swapOuts.GetById(7).Returns(swap); - NodePays(Payment.Types.PaymentStatus.Failed, PaymentFailureReason.FailureReasonIncorrectPaymentDetails); - - await Pay(); - - swap.Status.Should().Be(SwapOutStatus.Failed); - swap.ErrorDetails.Should().Contain("FailureReasonIncorrectPaymentDetails"); - _swapOuts.Received(1).Update(swap); - await _spark.DidNotReceiveWithAnyArgs().CreateInvoiceAsync(default, default, default, default, default); - } - - [Theory] - [InlineData(PaymentFailureReason.FailureReasonNoRoute)] - [InlineData(PaymentFailureReason.FailureReasonTimeout)] - [InlineData(PaymentFailureReason.FailureReasonInsufficientBalance)] - public async Task Pay_WhenAFailureASmallerAmountMayFix_SavesASmallerInvoice_ThenSchedulesItsPayment(PaymentFailureReason reason) - { - var swap = Pending(lightningFee: null); - _swapOuts.GetById(7).Returns(swap); - NodePays(Payment.Types.PaymentStatus.Failed, reason); - RetryInvoiceIssued(400_000); - - await Pay(); - - swap.Status.Should().Be(SwapOutStatus.Pending); - swap.Should().BeEquivalentTo(new - { - SatsAmount = 400_000L, PaymentHash = RetryHash, ProviderId = "req-2", SparkPaymentAttempt = 2, - SparkInvoiceExpiresAt = (DateTimeOffset?)DateTimeOffset.UnixEpoch.AddMinutes(90) - }, "the swap never points at the failed payment, so the monitor leaves it be"); - NSubstitute.Received.InOrder(() => - { - _swapOuts.Update(swap); - _scheduler.ScheduleJob( - Arg.Is(j => j.Key.Name == "SparkSwapPaymentJob-7-a2" && j.RequestsRecovery && - j.JobDataMap.GetString("paymentHash") == RetryHash && - j.JobDataMap.GetString("paymentRequest") == RetryInvoice), - Arg.Is(t => t.StartTimeUtc == _time.GetUtcNow().AddSeconds(60)), Arg.Any()); - }); - await _audit.Received(1).LogSystemAsync(AuditActionType.SwapOutPaymentRetryScheduled, AuditEventType.Attempt, - AuditObjectType.SwapOut, "7", Arg.Any()); - } - - [Fact] - public async Task Pay_EachRetryWaitsLonger() - { - var swap = Pending(lightningFee: null); - swap.SparkPaymentAttempt = 2; - swap.SatsAmount = 400_000; - _swapOuts.GetById(7).Returns(swap); - NodePays(Payment.Types.PaymentStatus.Failed, PaymentFailureReason.FailureReasonNoRoute); - RetryInvoiceIssued(320_000); - - await Pay(); - - swap.SparkPaymentAttempt.Should().Be(3); - await _scheduler.Received(1).ScheduleJob(Arg.Is(j => j.Key.Name == "SparkSwapPaymentJob-7-a3"), - Arg.Is(t => t.StartTimeUtc == _time.GetUtcNow().AddSeconds(120)), Arg.Any()); - } - - [Fact] - public async Task Pay_OnTheLastAttempt_FailsTheSwap() - { - var swap = Pending(lightningFee: null); - swap.SparkPaymentAttempt = Constants.SEND_PAYMENT_MAX_ATTEMPTS; - _swapOuts.GetById(7).Returns(swap); - NodePays(Payment.Types.PaymentStatus.Failed, PaymentFailureReason.FailureReasonNoRoute); - - await Pay(); - - swap.Status.Should().Be(SwapOutStatus.Failed); - swap.ErrorDetails.Should().Contain("FailureReasonNoRoute").And.Contain($"attempt {Constants.SEND_PAYMENT_MAX_ATTEMPTS}"); - await _spark.DidNotReceiveWithAnyArgs().CreateInvoiceAsync(default, default, default, default, default); - await _scheduler.DidNotReceiveWithAnyArgs().ScheduleJob(default!, default!, default); - } - - [Fact] - public async Task Pay_WhenTheSmallerAmountIsBelowTheNodesSparkMinSwap_FailsTheSwap() + public async Task Pay_WhenThePaymentFails_FailsTheSwap() { - _node.SparkSwapMinAmountSats = 450_000; var swap = Pending(lightningFee: null); _swapOuts.GetById(7).Returns(swap); NodePays(Payment.Types.PaymentStatus.Failed, PaymentFailureReason.FailureReasonNoRoute); @@ -418,71 +324,8 @@ public async Task Pay_WhenTheSmallerAmountIsBelowTheNodesSparkMinSwap_FailsTheSw await Pay(); swap.Status.Should().Be(SwapOutStatus.Failed); - await _spark.DidNotReceiveWithAnyArgs().CreateInvoiceAsync(default, default, default, default, default); - } - - [Fact] - public async Task Pay_WhenTheRetrysInvoiceCannotBeIssued_FailsTheSwap() - { - var swap = Pending(lightningFee: null); - _swapOuts.GetById(7).Returns(swap); - NodePays(Payment.Types.PaymentStatus.Failed, PaymentFailureReason.FailureReasonNoRoute); - _spark.CreateInvoiceAsync(W, 400_000, Arg.Any(), Arg.Any(), Arg.Any()) - .ThrowsAsync(new SparkConnectionException("request_lightning_receive", "the SSP is down")); - - await Pay(); - - swap.Status.Should().Be(SwapOutStatus.Failed); - swap.SatsAmount.Should().Be(500_000); - await _scheduler.DidNotReceiveWithAnyArgs().ScheduleJob(default!, default!, default); - } - - // ── Paying a scheduled retry ────────────────────────────────────────────────────────── - - private Task PayScheduled(string paymentHash = RetryHash) => - Service().PayScheduledAsync(7, paymentHash, RetryInvoice, 1m, null, 600); - - private SwapOut RetryPending() - { - var swap = Pending(lightningFee: null); - swap.SatsAmount = 400_000; - swap.PaymentHash = RetryHash; - swap.SparkPaymentAttempt = 2; - _swapOuts.GetById(7).Returns(swap); - return swap; - } - - [Fact] - public async Task PayScheduled_PaysTheRetrysInvoice_WithItsFeeLimit() - { - RetryPending(); - _lightning.TrackPaymentV2Async(_node, Arg.Any(), Arg.Any()).Returns((Payment?)null); - - await PayScheduled(); - - await _lightning.Received(1).SendPaymentV2Async(_node, RetryInvoice, 0, 4_000_000, null, null, 600, CancellationToken.None); - } - - [Fact] - public async Task PayScheduled_ASwapNoLongerWaitingForThatInvoice_IsNotPaid() - { - RetryPending(); - - await PayScheduled(paymentHash: Hash); - - await _lightning.DidNotReceiveWithAnyArgs().SendPaymentV2Async(default!, default!, default, default, default, default, default, default); - } - - [Fact] - public async Task PayScheduled_WhenTheNodeAlreadyHasItsPayment_LeavesItToTheMonitor() - { - RetryPending(); - _lightning.TrackPaymentV2Async(_node, Arg.Any(), Arg.Any()) - .Returns(new Payment { Status = Payment.Types.PaymentStatus.InFlight }); - - await PayScheduled(); - - await _lightning.DidNotReceiveWithAnyArgs().SendPaymentV2Async(default!, default!, default, default, default, default, default, default); + swap.ErrorDetails.Should().Contain("FailureReasonNoRoute"); + _swapOuts.Received(1).Update(swap); } [Fact] @@ -700,21 +543,6 @@ public async Task Advance_AnUnknownPayment_WaitsUntilTheInvoiceExpired_ThenFails response.ErrorMessage.Should().Contain("never reached the node").And.Contain("unknown"); } - [Fact] - public async Task Advance_AnUnknownPayment_WaitsForTheCurrentInvoice_NotTheFirstOne() - { - NodeReports(null); - var swap = Pending(lightningFee: null); - swap.SparkPaymentAttempt = 2; - swap.SparkInvoiceExpiresAt = DateTimeOffset.UnixEpoch.AddMinutes(90); - - _time.Set(DateTimeOffset.UnixEpoch.AddMinutes(95)); - (await Service().AdvanceSwapAsync(_node, swap)).Status.Should().Be(SwapOutStatus.Pending); - - _time.Set(DateTimeOffset.UnixEpoch.AddMinutes(101)); - (await Service().AdvanceSwapAsync(_node, swap)).Status.Should().Be(SwapOutStatus.Failed); - } - [Fact] public async Task Advance_ASwapPaidIntoAnotherSparkIdentity_IsRefused() { From 20f8b9584ac6b2c9590b6b8d73e337752e5f4a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:53:54 +0200 Subject: [PATCH 09/10] Undo the SEND_PAYMENT_* rename of the retry settings Only rebalances retry now, so REBALANCE_MAX_ATTEMPTS, REBALANCE_AMOUNT_BACKOFF_RATIO, REBALANCE_INITIAL_RETRY_DELAY_SECONDS and REBALANCE_RETRY_BACKOFF_MULTIPLIER keep their names, as on main. --- docs/rebalance-algorithm.md | 8 +-- src/Data/Models/Rebalance.cs | 4 +- src/Helpers/Constants.cs | 50 ++++++++----------- src/Proto/nodeguard.proto | 4 +- src/Services/RebalanceService.cs | 16 +++--- src/Shared/NewRebalanceModal.razor | 12 ++--- .../Services/RebalanceServiceTests.cs | 8 +-- 7 files changed, 47 insertions(+), 55 deletions(-) diff --git a/docs/rebalance-algorithm.md b/docs/rebalance-algorithm.md index 46b5d7af..ec4f5a10 100644 --- a/docs/rebalance-algorithm.md +++ b/docs/rebalance-algorithm.md @@ -336,10 +336,10 @@ which one gets drained can differ between runs on an otherwise unchanged node. | Constant | Default | Effect | |---|---|---| | `DEFAULT_REBALANCE_TIMEOUT_SECONDS` | `180` | Payment timeout when the caller supplies none. | -| `SEND_PAYMENT_MAX_ATTEMPTS` | `3` | Attempts per rebalance or Spark swap payment. `REBALANCE_MAX_ATTEMPTS` is still read as a fallback. | -| `SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS` | `60` | First retry delay. `REBALANCE_INITIAL_RETRY_DELAY_SECONDS` is still read as a fallback. | -| `SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER` | `2.0` | `delay = 60 · 2^(attempt−2)` ⇒ 60 s, 120 s. `REBALANCE_RETRY_BACKOFF_MULTIPLIER` is still read as a fallback. | -| `SEND_PAYMENT_AMOUNT_BACKOFF_RATIO` | `0.8` | Retries shrink the amount by this factor per attempt. `REBALANCE_AMOUNT_BACKOFF_RATIO` is still read as a fallback. | +| `REBALANCE_MAX_ATTEMPTS` | `3` | Attempts per rebalance. | +| `REBALANCE_INITIAL_RETRY_DELAY_SECONDS` | `60` | First retry delay. | +| `REBALANCE_RETRY_BACKOFF_MULTIPLIER` | `2.0` | `delay = 60 · 2^(attempt−2)` ⇒ 60 s, 120 s. | +| `REBALANCE_AMOUNT_BACKOFF_RATIO` | `0.8` | Retries shrink the amount by this factor per attempt. | | `SEND_PAYMENT_MAX_PARTS` | `32` | Max MPP shards of a payment sent with SendPaymentV2: rebalances and Spark swaps. `REBALANCE_MAX_PARTS` is still read as a fallback. | | `REBALANCE_DEFAULT_MAX_FEE_PCT` | `0.05` | Fallback cap when none is supplied (0.05 %). | | `REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT` | `0.05` | Fallback retry cap. | diff --git a/src/Data/Models/Rebalance.cs b/src/Data/Models/Rebalance.cs index 4e80896e..8f94f2bd 100644 --- a/src/Data/Models/Rebalance.cs +++ b/src/Data/Models/Rebalance.cs @@ -143,13 +143,13 @@ public static long WorstCaseFeeSats(long amountSats, double maxFeePct) /// 1 = never shrink (every attempt retries the full requested amount); 0.8 = each retry is /// 20% smaller; 0.5 = halve each time. The amount for attempt n is /// RequestedAmountSats × ratio^(n-1), floored at Constants.REBALANCE_MIN_AMOUNT_SATS. - /// When null, the runtime falls back to Constants.SEND_PAYMENT_AMOUNT_BACKOFF_RATIO. + /// When null, the runtime falls back to Constants.REBALANCE_AMOUNT_BACKOFF_RATIO. /// public double? AmountBackoffRatio { get; set; } /// /// Maximum number of attempts (including the first try). When null, falls back to - /// Constants.SEND_PAYMENT_MAX_ATTEMPTS + /// Constants.REBALANCE_MAX_ATTEMPTS /// public int? MaxAttempts { get; set; } diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index fe20434c..5673f35b 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -167,24 +167,21 @@ public class Constants public static decimal REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT = 0.05m; /// - /// Maximum number of attempts for a payment sent with SendPaymentV2, including the first try: rebalances and - /// Spark swap payments (SEND_PAYMENT_MAX_ATTEMPTS; REBALANCE_MAX_ATTEMPTS still read as a fallback). + /// Maximum number of attempts for a rebalance, including the first try. /// - public static int SEND_PAYMENT_MAX_ATTEMPTS = 3; + public static int REBALANCE_MAX_ATTEMPTS = 3; /// - /// Delay before the first retry attempt, in seconds (SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS; - /// REBALANCE_INITIAL_RETRY_DELAY_SECONDS still read as a fallback). Subsequent attempts use - /// SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER for exponential backoff. + /// Delay before the first retry attempt, in seconds. Subsequent attempts use + /// REBALANCE_RETRY_BACKOFF_MULTIPLIER for exponential backoff. /// - public static int SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS = 60; + public static int REBALANCE_INITIAL_RETRY_DELAY_SECONDS = 60; /// - /// Multiplier applied to the retry delay for each subsequent retry (SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER; - /// REBALANCE_RETRY_BACKOFF_MULTIPLIER still read as a fallback). With the default 2.0 and an initial delay of - /// 60s, retries fire at 60s, 120s, 240s, ... + /// Multiplier applied to the retry delay for each subsequent retry. With the default + /// 2.0 and an initial delay of 60s, retries fire at 60s, 120s, 240s, ... /// - public static double SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER = 2.0; + public static double REBALANCE_RETRY_BACKOFF_MULTIPLIER = 2.0; /// /// Smallest amount a rebalance attempt will shrink down to before giving up (the floor @@ -193,14 +190,13 @@ public class Constants public static long REBALANCE_MIN_AMOUNT_SATS = 10_000; /// - /// Multiplier applied to the paid amount on each retry attempt, for rebalances and Spark swap payments - /// (SEND_PAYMENT_AMOUNT_BACKOFF_RATIO; REBALANCE_AMOUNT_BACKOFF_RATIO still read as a fallback). Range: (0, 1]. + /// Multiplier applied to the rebalanced amount on each retry attempt. Range: (0, 1]. /// 1 = never shrink (every attempt retries the full requested amount); 0.8 = each retry - /// is 20% smaller than the previous attempt; 0.5 = halve each time. - /// A rebalance's amount for attempt n is RequestedAmountSats × ratio^(n-1), floored at - /// REBALANCE_MIN_AMOUNT_SATS; a Spark swap's is floored at the node's Spark min swap. + /// is 20% smaller than the previous attempt (partial rebalancing); 0.5 = halve each time. + /// The amount for attempt n is RequestedAmountSats × ratio^(n-1), floored at + /// REBALANCE_MIN_AMOUNT_SATS. /// - public static double SEND_PAYMENT_AMOUNT_BACKOFF_RATIO = 0.8; + public static double REBALANCE_AMOUNT_BACKOFF_RATIO = 0.8; /// /// Maximum number of partial payments (MPP shards) LND may split a payment sent with SendPaymentV2 into: @@ -598,24 +594,20 @@ static Constants() var rebRetryPct = Environment.GetEnvironmentVariable("REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT"); if (rebRetryPct != null) REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT = decimal.Parse(rebRetryPct, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture); - var sendPaymentMaxAttempts = Environment.GetEnvironmentVariable("SEND_PAYMENT_MAX_ATTEMPTS") - ?? Environment.GetEnvironmentVariable("REBALANCE_MAX_ATTEMPTS"); - if (sendPaymentMaxAttempts != null) SEND_PAYMENT_MAX_ATTEMPTS = int.Parse(sendPaymentMaxAttempts); + var rebMaxAttempts = Environment.GetEnvironmentVariable("REBALANCE_MAX_ATTEMPTS"); + if (rebMaxAttempts != null) REBALANCE_MAX_ATTEMPTS = int.Parse(rebMaxAttempts); - var sendPaymentInitialDelay = Environment.GetEnvironmentVariable("SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS") - ?? Environment.GetEnvironmentVariable("REBALANCE_INITIAL_RETRY_DELAY_SECONDS"); - if (sendPaymentInitialDelay != null) SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS = int.Parse(sendPaymentInitialDelay); + var rebInitialDelay = Environment.GetEnvironmentVariable("REBALANCE_INITIAL_RETRY_DELAY_SECONDS"); + if (rebInitialDelay != null) REBALANCE_INITIAL_RETRY_DELAY_SECONDS = int.Parse(rebInitialDelay); - var sendPaymentBackoff = Environment.GetEnvironmentVariable("SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER") - ?? Environment.GetEnvironmentVariable("REBALANCE_RETRY_BACKOFF_MULTIPLIER"); - if (sendPaymentBackoff != null) SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER = double.Parse(sendPaymentBackoff, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture); + var rebBackoff = Environment.GetEnvironmentVariable("REBALANCE_RETRY_BACKOFF_MULTIPLIER"); + if (rebBackoff != null) REBALANCE_RETRY_BACKOFF_MULTIPLIER = double.Parse(rebBackoff, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture); var rebMinAmount = Environment.GetEnvironmentVariable("REBALANCE_MIN_AMOUNT_SATS"); if (rebMinAmount != null) REBALANCE_MIN_AMOUNT_SATS = long.Parse(rebMinAmount); - var sendPaymentAmountBackoff = Environment.GetEnvironmentVariable("SEND_PAYMENT_AMOUNT_BACKOFF_RATIO") - ?? Environment.GetEnvironmentVariable("REBALANCE_AMOUNT_BACKOFF_RATIO"); - if (sendPaymentAmountBackoff != null) SEND_PAYMENT_AMOUNT_BACKOFF_RATIO = double.Parse(sendPaymentAmountBackoff, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture); + var rebProbeBackoff = Environment.GetEnvironmentVariable("REBALANCE_AMOUNT_BACKOFF_RATIO"); + if (rebProbeBackoff != null) REBALANCE_AMOUNT_BACKOFF_RATIO = double.Parse(rebProbeBackoff, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture); var sendPaymentMaxParts = Environment.GetEnvironmentVariable("SEND_PAYMENT_MAX_PARTS") ?? Environment.GetEnvironmentVariable("REBALANCE_MAX_PARTS"); diff --git a/src/Proto/nodeguard.proto b/src/Proto/nodeguard.proto index 0b8ebeaf..37cef55f 100644 --- a/src/Proto/nodeguard.proto +++ b/src/Proto/nodeguard.proto @@ -643,10 +643,10 @@ message RequestRebalanceRequest { // 1 = never shrink (every attempt retries the full amount); 0.8 = each retry is 20% // smaller; 0.5 = halve each time. The amount for attempt n is amount_sats * ratio^(n-1), // floored at REBALANCE_MIN_AMOUNT_SATS. If unset, falls back to - // Constants.SEND_PAYMENT_AMOUNT_BACKOFF_RATIO. + // Constants.REBALANCE_AMOUNT_BACKOFF_RATIO. optional double amount_backoff_ratio = 8; // Maximum number of attempts (including the first try). Must be >= 1. - // If unset, falls back to Constants.SEND_PAYMENT_MAX_ATTEMPTS + // If unset, falls back to Constants.REBALANCE_MAX_ATTEMPTS optional int32 max_attempts = 9; } diff --git a/src/Services/RebalanceService.cs b/src/Services/RebalanceService.cs index 794b165c..28838af2 100644 --- a/src/Services/RebalanceService.cs +++ b/src/Services/RebalanceService.cs @@ -211,7 +211,7 @@ public async Task ExecuteAsync(int rebalanceId, CancellationToken ct _logger.LogInformation( "Executing rebalance {RebalanceId} attempt {Attempt}/{MaxAttempts} (node={NodeId} '{NodeName}', amount={AmountSats} sats, maxFeePct={MaxFeePct}, timeoutSeconds={TimeoutSeconds})", - rebalance.Id, rebalance.AttemptNumber, rebalance.MaxAttempts ?? Constants.SEND_PAYMENT_MAX_ATTEMPTS, + rebalance.Id, rebalance.AttemptNumber, rebalance.MaxAttempts ?? Constants.REBALANCE_MAX_ATTEMPTS, node.Id, node.Name, rebalance.SatsAmount, rebalance.MaxFeePct, rebalance.TimeoutSeconds); try @@ -407,7 +407,7 @@ private static long ComputeFeeLimitMsat(long satsAmount, double maxFeePct) /// private static long ComputeAttemptAmountSats(Rebalance rebalance) { - var ratio = rebalance.AmountBackoffRatio ?? Constants.SEND_PAYMENT_AMOUNT_BACKOFF_RATIO; + var ratio = rebalance.AmountBackoffRatio ?? Constants.REBALANCE_AMOUNT_BACKOFF_RATIO; var scaled = (long)Math.Round( rebalance.RequestedAmountSats * Math.Pow(ratio, rebalance.AttemptNumber - 1), MidpointRounding.AwayFromZero); @@ -424,9 +424,9 @@ private static long ComputeAttemptAmountSats(Rebalance rebalance) /// private static long ComputeInvoiceExpirySeconds(Rebalance rebalance) { - var maxAttempts = Math.Max(1, rebalance.MaxAttempts ?? Constants.SEND_PAYMENT_MAX_ATTEMPTS); - var initialDelay = Constants.SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS; - var multiplier = Constants.SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER; + var maxAttempts = Math.Max(1, rebalance.MaxAttempts ?? Constants.REBALANCE_MAX_ATTEMPTS); + var initialDelay = Constants.REBALANCE_INITIAL_RETRY_DELAY_SECONDS; + var multiplier = Constants.REBALANCE_RETRY_BACKOFF_MULTIPLIER; // Mirrors ScheduleRetryIfEligibleAsync: delay between attempt N and N+1 is // initialDelay * multiplier^(N-1). Sum across all retries we might schedule. @@ -477,7 +477,7 @@ or RebalanceStatus.InsufficientBalance return; } - var maxAttempts = rebalance.MaxAttempts ?? Constants.SEND_PAYMENT_MAX_ATTEMPTS; + var maxAttempts = rebalance.MaxAttempts ?? Constants.REBALANCE_MAX_ATTEMPTS; if (rebalance.AttemptNumber >= maxAttempts) { _logger.LogInformation( @@ -497,8 +497,8 @@ or RebalanceStatus.InsufficientBalance rebalance.Status = RebalanceStatus.Pending; _rebalanceRepository.Update(rebalance); - var delaySeconds = Constants.SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS - * Math.Pow(Constants.SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER, nextAttempt - 2); + var delaySeconds = Constants.REBALANCE_INITIAL_RETRY_DELAY_SECONDS + * Math.Pow(Constants.REBALANCE_RETRY_BACKOFF_MULTIPLIER, nextAttempt - 2); var fireAt = DateTimeOffset.UtcNow.AddSeconds(delaySeconds); var scheduler = await _schedulerFactory.GetScheduler(); diff --git a/src/Shared/NewRebalanceModal.razor b/src/Shared/NewRebalanceModal.razor index 20cfb525..6a50a719 100644 --- a/src/Shared/NewRebalanceModal.razor +++ b/src/Shared/NewRebalanceModal.razor @@ -197,7 +197,7 @@ Min="0.01m" Max="1m"/> - @($"Server default: {Constants.SEND_PAYMENT_AMOUNT_BACKOFF_RATIO:0.00}. 1 = always retry the full amount; lower = shrink the amount on each retry.") + @($"Server default: {Constants.REBALANCE_AMOUNT_BACKOFF_RATIO:0.00}. 1 = always retry the full amount; lower = shrink the amount on each retry.") @@ -213,7 +213,7 @@ Min="1" Max="10"/> - @($"Server default: {Constants.SEND_PAYMENT_MAX_ATTEMPTS} attempts.") + @($"Server default: {Constants.REBALANCE_MAX_ATTEMPTS} attempts.") @@ -293,8 +293,8 @@ private decimal _maxFeePct = Constants.REBALANCE_DEFAULT_MAX_FEE_PCT; private decimal _retryMaxFeePct = Constants.REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT; private int _timeoutSeconds = Constants.DEFAULT_REBALANCE_TIMEOUT_SECONDS; - private decimal _amountBackoffRatio = (decimal)Constants.SEND_PAYMENT_AMOUNT_BACKOFF_RATIO; - private int _maxAttempts = Constants.SEND_PAYMENT_MAX_ATTEMPTS; + private decimal _amountBackoffRatio = (decimal)Constants.REBALANCE_AMOUNT_BACKOFF_RATIO; + private int _maxAttempts = Constants.REBALANCE_MAX_ATTEMPTS; private bool _showAdvanced; private bool _submitting; @@ -414,8 +414,8 @@ _maxFeePct = Constants.REBALANCE_DEFAULT_MAX_FEE_PCT; _retryMaxFeePct = Constants.REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT; _timeoutSeconds = Constants.DEFAULT_REBALANCE_TIMEOUT_SECONDS; - _amountBackoffRatio = (decimal)Constants.SEND_PAYMENT_AMOUNT_BACKOFF_RATIO; - _maxAttempts = Constants.SEND_PAYMENT_MAX_ATTEMPTS; + _amountBackoffRatio = (decimal)Constants.REBALANCE_AMOUNT_BACKOFF_RATIO; + _maxAttempts = Constants.REBALANCE_MAX_ATTEMPTS; _showAdvanced = false; _submitting = false; } diff --git a/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs b/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs index eedbaba7..91ba7140 100644 --- a/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs +++ b/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs @@ -450,8 +450,8 @@ public async Task ExecuteAsync_InvoiceExpiryCoversFullRetryWindowPlusBuffer() await service.RebalanceAsync(request); long expected = 60 - + Constants.SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS - + (long)(Constants.SEND_PAYMENT_INITIAL_RETRY_DELAY_SECONDS * Constants.SEND_PAYMENT_RETRY_BACKOFF_MULTIPLIER) + + Constants.REBALANCE_INITIAL_RETRY_DELAY_SECONDS + + (long)(Constants.REBALANCE_INITIAL_RETRY_DELAY_SECONDS * Constants.REBALANCE_RETRY_BACKOFF_MULTIPLIER) + 60; capturedExpiry.Should().Be(expected); capturedExpiry.Should().BeGreaterThan(60 + 60, "old TimeoutSeconds+60 expiry was demonstrably too short"); @@ -686,7 +686,7 @@ public async Task ExecuteAsync_AtMaxAttempts_NoFurtherRetryScheduled() NodeId = node.Id, Node = node, Status = RebalanceStatus.Pending, - AttemptNumber = Constants.SEND_PAYMENT_MAX_ATTEMPTS, + AttemptNumber = Constants.REBALANCE_MAX_ATTEMPTS, RequestedAmountSats = 100_000, SatsAmount = 100_000, MaxFeePct = 0.05, @@ -705,7 +705,7 @@ public async Task ExecuteAsync_AtMaxAttempts_NoFurtherRetryScheduled() var result = await service.ExecuteAsync(existing.Id); result.Status.Should().Be(RebalanceStatus.NoRoute); // terminal, no escalation - result.AttemptNumber.Should().Be(Constants.SEND_PAYMENT_MAX_ATTEMPTS); + result.AttemptNumber.Should().Be(Constants.REBALANCE_MAX_ATTEMPTS); _scheduler.Verify(s => s.ScheduleJob(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } From 10cd8ec69d0c62c9727eb2f5903c6f0405fba3f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:42:47 +0200 Subject: [PATCH 10/10] Max swaps in flight per provider: Loop, 40swap and Spark each have their own A node's single Max swaps in flight becomes one per provider, so Loop can run one swap at a time while Spark runs several. Auto-liquidity counts each provider's swaps in flight and leaves out a provider at its max, choosing among the others; with every weighted provider at its max it swaps nothing. Spark's creation checks Spark's own. The Nodes page sets each next to the provider's swap sizes, and the grid shows them. The single migration renames the shared value to Loop's and copies it to 40swap's and Spark's. --- docs/spark-swaps.md | 8 +-- src/Data/Models/Node.cs | 17 +++++-- src/Jobs/AutoLiquidityManagementJob.cs | 51 +++++++++++-------- ...SwapLimitsAndSparkExitBatches.Designer.cs} | 16 ++++-- ...rProviderSwapLimitsAndSparkExitBatches.cs} | 42 +++++++++++++-- .../ApplicationDbContextModelSnapshot.cs | 12 +++-- src/Pages/Nodes.razor | 45 ++++++++++++---- src/Services/Spark/SparkSwapService.cs | 8 +-- ...pAmountLimits.cs => SwapProviderLimits.cs} | 18 ++++--- src/Shared/NewSwapModal.razor | 4 +- .../E2E/SparkSwapOutE2ETests.cs | 6 +-- .../Jobs/AutoLiquidityManagementJobTests.cs | 44 +++++++++++++++- .../Services/Spark/SparkSwapServiceTests.cs | 8 +-- 13 files changed, 209 insertions(+), 70 deletions(-) rename src/Migrations/{20261016135127_AddPerProviderSwapAmountsAndSparkExitBatches.Designer.cs => 20261016144038_AddPerProviderSwapLimitsAndSparkExitBatches.Designer.cs} (99%) rename src/Migrations/{20261016135127_AddPerProviderSwapAmountsAndSparkExitBatches.cs => 20261016144038_AddPerProviderSwapLimitsAndSparkExitBatches.cs} (65%) rename src/Services/{SwapAmountLimits.cs => SwapProviderLimits.cs} (66%) diff --git a/docs/spark-swaps.md b/docs/spark-swaps.md index 9641e22d..3da445e6 100644 --- a/docs/spark-swaps.md +++ b/docs/spark-swaps.md @@ -11,7 +11,7 @@ The Spark client is the [NSpark](https://github.com/orklabs/nspark) .NET SDK, fr ## How a swap runs **Creation.** Swaps are created by auto-liquidity, the New Swap dialog, or the `RequestSwapOut` RPC. A swap goes through the node's Spark wallet (see [Signing](#signing)). -1. A node may have up to its **Max swaps in flight** Spark swaps being paid at once (the node setting on the Nodes page; 0 counts as 1). Once paid, a swap waits in the wallet, whose max balance limits it, and no longer counts. An in-process lock serializes the check and the record, so concurrent requests can't go over it. +1. A node may have up to its **Spark max swaps in flight** Spark swaps being paid at once (the node's liquidity settings on the Nodes page; 0 counts as 1). Once paid, a swap waits in the wallet, whose max balance limits it, and no longer counts. An in-process lock serializes the check and the record, so concurrent requests can't go over it. 2. A swap that would bring the wallet over its max balance, counting what its swaps not paid yet will still bring in, is refused. 3. The Spark wallet issues an invoice through the SSP. An invoice without a receive request is refused before anything is paid: the swap's funds are found only through the transfer the SSP reports for it. 4. The swap is recorded before anything is paid: its payment hash, destination wallet and the Spark identity that will receive it. No address is reserved yet: the swap needs one only when it exits. @@ -101,8 +101,8 @@ With the remote signer, `REMOTE_SIGNER_ENDPOINT` must be the Function URL with n ## Using it -- **Auto-liquidity.** Give the node a Spark weight and a Spark wallet on the Nodes page; the Loop, 40swap and Spark weights sum to 100. The node's Max swaps in flight limits Spark swaps like any other, and Spark is skipped while the node's Spark wallet is unavailable. - - Each provider has its own min and max swap size on the node (Loop, 40swap and Spark; Spark's default to `SPARK_SWAP_MIN_SATS` / `SPARK_SWAP_MAX_SATS`). Auto-liquidity picks the provider first, among those whose min fits what is above the balance threshold, then swaps the most that provider takes. +- **Auto-liquidity.** Give the node a Spark weight and a Spark wallet on the Nodes page; the Loop, 40swap and Spark weights sum to 100. Spark is skipped while the node's Spark wallet is unavailable. + - Each provider has its own min and max swap size and its own max swaps in flight on the node (Loop, 40swap and Spark; Spark's sizes default to `SPARK_SWAP_MIN_SATS` / `SPARK_SWAP_MAX_SATS`), so Loop can run one swap at a time while Spark runs several. Auto-liquidity picks the provider first, among those below their max swaps in flight whose min fits what is above the balance threshold, then swaps the most that provider takes. With every weighted provider at its max, the run is skipped. - A Spark swap is also sized down to the room left under the wallet's max balance (counting its swaps not paid yet), and Spark is skipped when that room is below the node's Spark min swap. If no other weighted provider is left, the run is skipped without reserving an address. - **New Swap dialog.** Spark lists the nodes whose Spark wallet is ready. There is no up-front quote: the SSP quotes the exit for the leaves the payment brings in. The confirmation shows the caps instead, with their percentage of the swap. - **gRPC.** @@ -136,7 +136,7 @@ The remote signer refuses to sign Spark operations with a `Compromised` seed. A `just spark-up` starts a regtest Spark network (three operators, the open-source open-ssp SSP and its Lightning node) on the Polar chain; see [docker/spark/README.md](../docker/spark/README.md). -`just test-e2e` and the CI `e2e-test` job run the whole E2E suite with the network up, including `SparkSwapOutE2ETests`. It creates a Spark wallet over gRPC with a max balance two swaps fill, gives it to alice in the database with Max swaps in flight 2 and a Spark min swap no third swap fits, restarts NodeGuard (the seed must decrypt after a restart), and starts two swaps at once. Once both are paid the wallet is full: they must exit together, in one exit to one address, each recording its share of the payout, with its on-chain and service fees adding up to the amount minus its payout, and the Spark wallet must end empty. A second test holds a swap's exit (NBXplorer stopped, so no exit address can be reserved), ages its leaves to their timelock floor with the local operators' test RPC (`mock.MockService/modify_node_timelock`), then lets the exit run: it must renew the leaves (one through a node renewal, the rest through refund renewals) and move exactly them, under their own ids, with the amounts adding up. +`just test-e2e` and the CI `e2e-test` job run the whole E2E suite with the network up, including `SparkSwapOutE2ETests`. It creates a Spark wallet over gRPC with a max balance two swaps fill, gives it to alice in the database with Spark max swaps in flight 2 and a Spark min swap no third swap fits, restarts NodeGuard (the seed must decrypt after a restart), and starts two swaps at once. Once both are paid the wallet is full: they must exit together, in one exit to one address, each recording its share of the payout, with its on-chain and service fees adding up to the amount minus its payout, and the Spark wallet must end empty. A second test holds a swap's exit (NBXplorer stopped, so no exit address can be reserved), ages its leaves to their timelock floor with the local operators' test RPC (`mock.MockService/modify_node_timelock`), then lets the exit run: it must renew the leaves (one through a node renewal, the rest through refund renewals) and move exactly them, under their own ids, with the amounts adding up. ## Before enabling it on mainnet diff --git a/src/Data/Models/Node.cs b/src/Data/Models/Node.cs index e6a93694..d981a3a3 100644 --- a/src/Data/Models/Node.cs +++ b/src/Data/Models/Node.cs @@ -110,9 +110,20 @@ public class Node : Entity public long? SparkSwapMaxAmountSats { get; set; } /// - /// Maximum number of concurrent swaps to limit HTLC locking + /// Maximum number of concurrent Loop swaps, to limit HTLC locking /// - public int MaxSwapsInFlight { get; set; } + public int LoopMaxSwapsInFlight { get; set; } + + /// + /// Maximum number of concurrent 40swap swaps, to limit HTLC locking + /// + public int FortySwapMaxSwapsInFlight { get; set; } + + /// + /// Maximum number of Spark swaps being paid at once, to limit HTLC locking. Once paid, a Spark swap waits in + /// its Spark wallet, whose max balance limits it + /// + public int SparkMaxSwapsInFlight { get; set; } /// /// Maximum swap routing fee ratio acceptable for swaps as a decimal between 0 and 1 @@ -155,7 +166,7 @@ public class Node : Entity /// /// Weight for the Spark swap provider selection (default 0). Spark is skipped while the node's - /// Spark wallet is unavailable; MaxSwapsInFlight limits Spark swaps like any other + /// Spark wallet is unavailable; SparkMaxSwapsInFlight limits its swaps /// public int SparkSwapWeight { get; set; } = 0; diff --git a/src/Jobs/AutoLiquidityManagementJob.cs b/src/Jobs/AutoLiquidityManagementJob.cs index e2dfee55..39943589 100644 --- a/src/Jobs/AutoLiquidityManagementJob.cs +++ b/src/Jobs/AutoLiquidityManagementJob.cs @@ -120,16 +120,8 @@ public async Task Execute(IJobExecutionContext context) _logger.LogInformation("{JobName} ended", nameof(AutoLiquidityManagementJob)); } - /// - /// Weighted random choice among the providers with a weight that can take a swap now, and the swap it takes: - /// the most it may, within its max swap, the excess balance and, for Spark, the room left in the node's Spark - /// wallet under its max balance. A provider is left out when that is below its min swap, and Spark also while - /// the node has no Spark wallet or its wallet is unavailable (the node's Max swaps in flight already limits its - /// swaps). All weights at 0 means Loop, as before. Null when no weighted provider is left, with whether the - /// excess balance was below every one's min swap. - /// - private async Task<(SwapProvider? Provider, long AmountSats, bool BelowMinimum)> SelectSwapProviderAsync(Node node, - long excessBalance) + /// The providers with a weight; all weights at 0 means Loop, as before. + private List<(SwapProvider Provider, int Weight)> WeightedProviders(Node node) { var weights = new List<(SwapProvider Provider, int Weight)> { @@ -141,14 +133,27 @@ public async Task Execute(IJobExecutionContext context) if (weights.All(w => w.Weight <= 0)) { _logger.LogWarning("Node {NodeName} has total weight of 0, defaulting to Loop", node.Name); - weights = [(SwapProvider.Loop, 1)]; + return [(SwapProvider.Loop, 1)]; } + return weights.Where(w => w.Weight > 0).ToList(); + } + + /// + /// Weighted random choice among (the weighted providers below their max swaps in + /// flight) that can take a swap now, and the swap it takes: the most it may, within its max swap, the excess + /// balance and, for Spark, the room left in the node's Spark wallet under its max balance. A provider is left out + /// when that is below its min swap, and Spark also while the node has no Spark wallet or its wallet is + /// unavailable. Null when no provider is left, with whether the excess balance was below every one's min swap. + /// + private async Task<(SwapProvider? Provider, long AmountSats, bool BelowMinimum)> SelectSwapProviderAsync(Node node, + IReadOnlyList<(SwapProvider Provider, int Weight)> weights, long excessBalance) + { var candidates = new List<(SwapProvider Provider, int Weight, long AmountSats)>(); var belowMinimum = true; - foreach (var (provider, weight) in weights.Where(w => w.Weight > 0)) + foreach (var (provider, weight) in weights) { - var limits = SwapAmountLimits.For(node, provider, _sparkSettings); + var limits = SwapProviderLimits.For(node, provider, _sparkSettings); var amount = Math.Min(excessBalance, limits.MaxSats); if (amount < limits.MinSats) { @@ -265,17 +270,21 @@ public async Task ManageNodeLiquidity(Node node, Canc return ManageNodeLiquidityResult.BalanceBelowThreshold; } - // Get in-flight swaps for this node (optimistic locking) + // Swaps in flight count per provider (optimistic locking): one may run serialized while another runs in + // parallel. A provider at its max is left out; with every weighted one at its max, nothing is swapped var inFlightSwaps = await _swapOutRepository.GetInFlightSwapsByNode(node.Id); - var inFlightCount = inFlightSwaps.Count; + var weighted = WeightedProviders(node); + var available = weighted + .Where(w => inFlightSwaps.Count(s => s.Provider == w.Provider) < SwapProviderLimits.For(node, w.Provider, _sparkSettings).MaxInFlight) + .ToList(); - _logger.LogDebug("Node {NodeName} has {InFlightCount} swaps in flight, max: {MaxSwapsInFlight}", - node.Name, inFlightCount, node.MaxSwapsInFlight); + _logger.LogDebug("Node {NodeName} has {InFlightCount} swaps in flight; providers below their max: {Providers}", + node.Name, inFlightSwaps.Count, string.Join(", ", available.Select(w => w.Provider))); - if (inFlightCount >= node.MaxSwapsInFlight) + if (available.Count == 0) { - _logger.LogInformation("Node {NodeName} has reached max swaps in flight ({MaxSwapsInFlight})", - node.Name, node.MaxSwapsInFlight); + _logger.LogInformation("Node {NodeName} has reached max swaps in flight of every weighted provider ({Providers})", + node.Name, string.Join(", ", weighted.Select(w => w.Provider))); return ManageNodeLiquidityResult.MaxSwapsInFlightReached; } @@ -302,7 +311,7 @@ public async Task ManageNodeLiquidity(Node node, Canc // Pick the provider among those whose own min and max swap fit what's above the threshold (the excess // balance), before reserving an address for a swap that may not happen: the swap is the most it takes - var (provider, swapAmount, belowMinimum) = await SelectSwapProviderAsync(node, excessBalance); + var (provider, swapAmount, belowMinimum) = await SelectSwapProviderAsync(node, available, excessBalance); if (provider is not { } selectedProvider) { if (belowMinimum) diff --git a/src/Migrations/20261016135127_AddPerProviderSwapAmountsAndSparkExitBatches.Designer.cs b/src/Migrations/20261016144038_AddPerProviderSwapLimitsAndSparkExitBatches.Designer.cs similarity index 99% rename from src/Migrations/20261016135127_AddPerProviderSwapAmountsAndSparkExitBatches.Designer.cs rename to src/Migrations/20261016144038_AddPerProviderSwapLimitsAndSparkExitBatches.Designer.cs index abec2403..aa7557f6 100644 --- a/src/Migrations/20261016135127_AddPerProviderSwapAmountsAndSparkExitBatches.Designer.cs +++ b/src/Migrations/20261016144038_AddPerProviderSwapLimitsAndSparkExitBatches.Designer.cs @@ -14,8 +14,8 @@ namespace NodeGuard.Migrations { [DbContext(typeof(ApplicationDbContext))] - [Migration("20261016135127_AddPerProviderSwapAmountsAndSparkExitBatches")] - partial class AddPerProviderSwapAmountsAndSparkExitBatches + [Migration("20261016144038_AddPerProviderSwapLimitsAndSparkExitBatches")] + partial class AddPerProviderSwapLimitsAndSparkExitBatches { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -1017,6 +1017,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("FortySwapMaxAmountSats") .HasColumnType("bigint"); + b.Property("FortySwapMaxSwapsInFlight") + .HasColumnType("integer"); + b.Property("FortySwapMinAmountSats") .HasColumnType("bigint"); @@ -1029,6 +1032,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("IsNodeDisabled") .HasColumnType("boolean"); + b.Property("LoopMaxSwapsInFlight") + .HasColumnType("integer"); + b.Property("LoopSwapMaxAmountSats") .HasColumnType("bigint"); @@ -1056,9 +1062,6 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Property("MaxSwapRoutingFeeRatio") .HasColumnType("numeric"); - b.Property("MaxSwapsInFlight") - .HasColumnType("integer"); - b.Property("MinimumBalanceThresholdSats") .HasColumnType("bigint"); @@ -1084,6 +1087,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("boolean") .HasDefaultValue(false); + b.Property("SparkMaxSwapsInFlight") + .HasColumnType("integer"); + b.Property("SparkPaymentTimeoutMinutes") .HasColumnType("integer"); diff --git a/src/Migrations/20261016135127_AddPerProviderSwapAmountsAndSparkExitBatches.cs b/src/Migrations/20261016144038_AddPerProviderSwapLimitsAndSparkExitBatches.cs similarity index 65% rename from src/Migrations/20261016135127_AddPerProviderSwapAmountsAndSparkExitBatches.cs rename to src/Migrations/20261016144038_AddPerProviderSwapLimitsAndSparkExitBatches.cs index 2b26053a..b57d626d 100644 --- a/src/Migrations/20261016135127_AddPerProviderSwapAmountsAndSparkExitBatches.cs +++ b/src/Migrations/20261016144038_AddPerProviderSwapLimitsAndSparkExitBatches.cs @@ -6,7 +6,7 @@ namespace NodeGuard.Migrations { /// - public partial class AddPerProviderSwapAmountsAndSparkExitBatches : Migration + public partial class AddPerProviderSwapLimitsAndSparkExitBatches : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -21,6 +21,11 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "Nodes", newName: "LoopSwapMaxAmountSats"); + migrationBuilder.RenameColumn( + name: "MaxSwapsInFlight", + table: "Nodes", + newName: "LoopMaxSwapsInFlight"); + migrationBuilder.AddColumn( name: "SparkExitDatetime", table: "SwapOuts", @@ -34,6 +39,13 @@ protected override void Up(MigrationBuilder migrationBuilder) nullable: false, defaultValue: 0L); + migrationBuilder.AddColumn( + name: "FortySwapMaxSwapsInFlight", + table: "Nodes", + type: "integer", + nullable: false, + defaultValue: 0); + migrationBuilder.AddColumn( name: "FortySwapMinAmountSats", table: "Nodes", @@ -41,9 +53,12 @@ protected override void Up(MigrationBuilder migrationBuilder) nullable: false, defaultValue: 0L); - // The node's shared swap sizes become Loop's (the rename above) and 40swap's too - migrationBuilder.Sql( - "UPDATE \"Nodes\" SET \"FortySwapMinAmountSats\" = \"LoopSwapMinAmountSats\", \"FortySwapMaxAmountSats\" = \"LoopSwapMaxAmountSats\";"); + migrationBuilder.AddColumn( + name: "SparkMaxSwapsInFlight", + table: "Nodes", + type: "integer", + nullable: false, + defaultValue: 0); migrationBuilder.AddColumn( name: "SparkSwapMaxAmountSats", @@ -56,6 +71,12 @@ protected override void Up(MigrationBuilder migrationBuilder) table: "Nodes", type: "bigint", nullable: true); + + // The node's shared swap sizes and max swaps in flight become Loop's (the renames above), and 40swap's + // and Spark's max swaps in flight too; Spark's sizes default to SPARK_SWAP_MIN_SATS / SPARK_SWAP_MAX_SATS + migrationBuilder.Sql( + "UPDATE \"Nodes\" SET \"FortySwapMinAmountSats\" = \"LoopSwapMinAmountSats\", \"FortySwapMaxAmountSats\" = \"LoopSwapMaxAmountSats\", " + + "\"FortySwapMaxSwapsInFlight\" = \"LoopMaxSwapsInFlight\", \"SparkMaxSwapsInFlight\" = \"LoopMaxSwapsInFlight\";"); } /// @@ -69,10 +90,18 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "FortySwapMaxAmountSats", table: "Nodes"); + migrationBuilder.DropColumn( + name: "FortySwapMaxSwapsInFlight", + table: "Nodes"); + migrationBuilder.DropColumn( name: "FortySwapMinAmountSats", table: "Nodes"); + migrationBuilder.DropColumn( + name: "SparkMaxSwapsInFlight", + table: "Nodes"); + migrationBuilder.DropColumn( name: "SparkSwapMaxAmountSats", table: "Nodes"); @@ -81,6 +110,11 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "SparkSwapMinAmountSats", table: "Nodes"); + migrationBuilder.RenameColumn( + name: "LoopMaxSwapsInFlight", + table: "Nodes", + newName: "MaxSwapsInFlight"); + migrationBuilder.RenameColumn( name: "LoopSwapMinAmountSats", table: "Nodes", diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index ab4ed356..1dcf6f15 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1014,6 +1014,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("FortySwapMaxAmountSats") .HasColumnType("bigint"); + b.Property("FortySwapMaxSwapsInFlight") + .HasColumnType("integer"); + b.Property("FortySwapMinAmountSats") .HasColumnType("bigint"); @@ -1026,6 +1029,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsNodeDisabled") .HasColumnType("boolean"); + b.Property("LoopMaxSwapsInFlight") + .HasColumnType("integer"); + b.Property("LoopSwapMaxAmountSats") .HasColumnType("bigint"); @@ -1053,9 +1059,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("MaxSwapRoutingFeeRatio") .HasColumnType("numeric"); - b.Property("MaxSwapsInFlight") - .HasColumnType("integer"); - b.Property("MinimumBalanceThresholdSats") .HasColumnType("bigint"); @@ -1081,6 +1084,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("boolean") .HasDefaultValue(false); + b.Property("SparkMaxSwapsInFlight") + .HasColumnType("integer"); + b.Property("SparkPaymentTimeoutMinutes") .HasColumnType("integer"); diff --git a/src/Pages/Nodes.razor b/src/Pages/Nodes.razor index 713a365a..529a4ec1 100644 --- a/src/Pages/Nodes.razor +++ b/src/Pages/Nodes.razor @@ -184,6 +184,9 @@ @SparkSwapSizeText(context.SparkSwapMaxAmountSats, SparkSettings.SwapMaxSats) + + + @@ -337,7 +340,7 @@ Spark Weight - Weight for the node's Spark wallet (0-100). Skipped while it is unavailable; Max Swaps In Flight limits Spark swaps too + Weight for the node's Spark wallet (0-100). Skipped while it is unavailable; Spark Max Swaps in Flight limits its swaps - @* Each provider has its own swap sizes: auto-liquidity picks the provider first, then swaps the most it takes *@ + @* Each provider has its own swap sizes and max swaps in flight: auto-liquidity picks a provider below its max, then swaps the most it takes *@ @@ -438,6 +441,17 @@ + + + Loop Max Swaps in Flight + Concurrent Loop swaps (0-100), to limit HTLC locking + + + Must be between 0 and 100 + + + + @@ -480,6 +494,17 @@ + + + 40swap Max Swaps in Flight + Concurrent 40swap swaps (0-100), to limit HTLC locking + + + Must be between 0 and 100 + + + + @if (SparkSettings.Enabled || _selectedNodeForLiquidity.SparkSwapWeight > 0) { @@ -524,20 +549,20 @@ - - } - - Max Swaps in Flight - Maximum concurrent swaps (0-100) to limit HTLC locking - + Spark Max Swaps in Flight + Spark swaps being paid at once (0-100; 0 still lets one manual swap through). Once paid, a swap waits in the Spark wallet and no longer counts + Must be between 0 and 100 + + } + Max Swap Routing Fee @@ -824,7 +849,9 @@ public static readonly ColumnDefault FortySwapMaxAmount = new("40swap Max Swap Amount",false); public static readonly ColumnDefault SparkSwapMinAmount = new("Spark Min Swap Amount",false); public static readonly ColumnDefault SparkSwapMaxAmount = new("Spark Max Swap Amount",false); - public static readonly ColumnDefault MaxSwapsInFlight = new("Max Swaps In Flight",false); + public static readonly ColumnDefault LoopMaxSwapsInFlight = new("Loop Max Swaps In Flight",false); + public static readonly ColumnDefault FortySwapMaxSwapsInFlight = new("40swap Max Swaps In Flight",false); + public static readonly ColumnDefault SparkMaxSwapsInFlight = new("Spark Max Swaps In Flight",false); public static readonly ColumnDefault MaxSwapFeeRatio = new("Max Swap Fee Ratio",false); public static readonly ColumnDefault MinimumBalanceThreshold = new("Minimum Balance Threshold",false); public static readonly ColumnDefault SwapBudget = new("Swap Budget",false); diff --git a/src/Services/Spark/SparkSwapService.cs b/src/Services/Spark/SparkSwapService.cs index 49f50853..68a1dfc3 100644 --- a/src/Services/Spark/SparkSwapService.cs +++ b/src/Services/Spark/SparkSwapService.cs @@ -196,15 +196,15 @@ public async Task CreateSwapOutAsync(Node node, SwapOut swapOut await Reservation.WaitAsync(ct); try { - // Each swap exits its own leaves, so several can be in flight: up to the node's Max swaps in flight - var limit = Math.Max(node.MaxSwapsInFlight, 1); + // Each swap exits its own leaves, so several can be in flight: up to the node's Spark max swaps in flight + var limit = Math.Max(node.SparkMaxSwapsInFlight, 1); var pending = await _swapOutRepository.GetAllPending(); // Once paid, a swap waits in the wallet, whose max balance limits it: only swaps being paid are in flight var inFlight = pending.Count(s => s.Provider == SwapProvider.Spark && s.NodeId == node.Id && s.LightningFeeSats is null); if (inFlight >= limit) { throw new InvalidOperationException( - $"{node.Name} already has {inFlight} Spark swaps in flight, its limit (Max swaps in flight: {limit})."); + $"{node.Name} already has {inFlight} Spark swaps in flight, its limit (Spark max swaps in flight: {limit})."); } // Transit only: refuse a swap that would leave more than the wallet's max balance in it, counting what @@ -424,7 +424,7 @@ public async Task>> PlanExitsAsync(IReadOnl var room = SparkGuardrails.RoomSats(entry.MaxBalanceSats, balance, ofWallet); var smallestSwap = nodes .Where(n => _settings.WalletFor(n) == entry.Ref) - .Select(n => (long?)SwapAmountLimits.For(n, SwapProvider.Spark, _settings).MinSats) + .Select(n => (long?)SwapProviderLimits.For(n, SwapProvider.Spark, _settings).MinSats) .Min(); if (smallestSwap is { } min && room >= min) { diff --git a/src/Services/SwapAmountLimits.cs b/src/Services/SwapProviderLimits.cs similarity index 66% rename from src/Services/SwapAmountLimits.cs rename to src/Services/SwapProviderLimits.cs index c87bbcc7..46847335 100644 --- a/src/Services/SwapAmountLimits.cs +++ b/src/Services/SwapProviderLimits.cs @@ -22,18 +22,22 @@ namespace NodeGuard.Services; -/// The smallest and largest swap-out a node makes through a provider. -public readonly record struct SwapAmountLimits(long MinSats, long MaxSats) +/// +/// The smallest and largest swap-out a node makes through a provider, and how many of them it runs at once: one +/// provider may run serialized while another runs in parallel. +/// +public readonly record struct SwapProviderLimits(long MinSats, long MaxSats, int MaxInFlight) { /// /// 's limits for : Loop's and 40swap's are the node's own; - /// Spark's are the node's own, else SPARK_SWAP_MIN_SATS / SPARK_SWAP_MAX_SATS. + /// Spark's are the node's own, its sizes else SPARK_SWAP_MIN_SATS / SPARK_SWAP_MAX_SATS. /// - public static SwapAmountLimits For(Node node, SwapProvider provider, SparkSettings spark) => provider switch + public static SwapProviderLimits For(Node node, SwapProvider provider, SparkSettings spark) => provider switch { - SwapProvider.Loop => new(node.LoopSwapMinAmountSats, node.LoopSwapMaxAmountSats), - SwapProvider.FortySwap => new(node.FortySwapMinAmountSats, node.FortySwapMaxAmountSats), - SwapProvider.Spark => new(node.SparkSwapMinAmountSats ?? spark.SwapMinSats, node.SparkSwapMaxAmountSats ?? spark.SwapMaxSats), + SwapProvider.Loop => new(node.LoopSwapMinAmountSats, node.LoopSwapMaxAmountSats, node.LoopMaxSwapsInFlight), + SwapProvider.FortySwap => new(node.FortySwapMinAmountSats, node.FortySwapMaxAmountSats, node.FortySwapMaxSwapsInFlight), + SwapProvider.Spark => new(node.SparkSwapMinAmountSats ?? spark.SwapMinSats, node.SparkSwapMaxAmountSats ?? spark.SwapMaxSats, + node.SparkMaxSwapsInFlight), _ => throw new ArgumentOutOfRangeException(nameof(provider), provider, "Unknown swap provider") }; } diff --git a/src/Shared/NewSwapModal.razor b/src/Shared/NewSwapModal.razor index 1f2d0515..b85489be 100644 --- a/src/Shared/NewSwapModal.razor +++ b/src/Shared/NewSwapModal.razor @@ -523,7 +523,7 @@ { var maxRoutingFee = new Money(_maxRoutingFeesPercent * _amountBtc / 100, MoneyUnit.BTC); var maxExitFee = new Money(SparkSettings.MaxExitFeeSats); - var maxSwapsInFlight = Math.Max(_selectedNode?.MaxSwapsInFlight ?? 0, 1); + var maxSwapsInFlight = Math.Max(_selectedNode?.SparkMaxSwapsInFlight ?? 0, 1); _confirmationModalBody = "You are about to create a new swap with the following details:

" + middleChunkMessage + @@ -539,7 +539,7 @@ "Once paid, the swap waits in the Spark wallet until the wallet is full (no room left for another swap under its max balance), " + "then exits with the wallet's other paid swaps in one on-chain transaction to the destination wallet, sharing its fee, " + $"at exit speed {SparkSettings.ExitSpeedSummary}. " + - $"Up to {maxSwapsInFlight} Spark swaps of {_selectedNode?.Name} (its Max swaps in flight) are paid at once.

" + + $"Up to {maxSwapsInFlight} Spark swaps of {_selectedNode?.Name} (its Spark max swaps in flight) are paid at once.

" + "Please confirm that you want to proceed."; await _confirmationModal.ShowModal(new Dictionary diff --git a/test/NodeGuard.Tests/E2E/SparkSwapOutE2ETests.cs b/test/NodeGuard.Tests/E2E/SparkSwapOutE2ETests.cs index 0e7d045c..80b361bf 100644 --- a/test/NodeGuard.Tests/E2E/SparkSwapOutE2ETests.cs +++ b/test/NodeGuard.Tests/E2E/SparkSwapOutE2ETests.cs @@ -56,7 +56,7 @@ public SparkE2EFactAttribute() /// A Spark swap-out through NodeGuard's gRPC API against the local Spark network (docker/spark): /// /// a Spark wallet is created over gRPC, with a max balance two swaps fill, and made alice's Spark wallet, with -/// Max swaps in flight 2 and a Spark min swap no third swap fits (node liquidity config, set in the database as the +/// Spark max swaps in flight 2 and a Spark min swap no third swap fits (node liquidity config, set in the database as the /// Nodes page would); /// NodeGuard restarts, so the wallet's seed has to decrypt with the Data Protection key ring in Postgres; /// two swaps of different amounts start at once. For each, alice's LND pays an invoice of the Spark wallet, @@ -314,7 +314,7 @@ private async Task StartSwapAsync(NodeGuardService.NodeG } /// - /// A node's Spark wallet, Max swaps in flight and Spark min swap are liquidity config, set on the Nodes page; there is + /// A node's Spark wallet, Spark max swaps in flight and Spark min swap are liquidity config, set on the Nodes page; there is /// no RPC for them /// private static async Task SetSparkConfigAsync(int nodeId, int sparkWalletId, int maxSwapsInFlight, long sparkMinSwapSats) @@ -323,7 +323,7 @@ private static async Task SetSparkConfigAsync(int nodeId, int sparkWalletId, int "Host=localhost;Port=5432;Database=nodeguard;User ID=postgres;")); await connection.OpenAsync(); await using var command = new NpgsqlCommand( - "UPDATE \"Nodes\" SET \"SparkWalletId\" = @wallet, \"MaxSwapsInFlight\" = @maxSwapsInFlight, " + + "UPDATE \"Nodes\" SET \"SparkWalletId\" = @wallet, \"SparkMaxSwapsInFlight\" = @maxSwapsInFlight, " + "\"SparkSwapMinAmountSats\" = @sparkMinSwap WHERE \"Id\" = @node", connection); command.Parameters.AddWithValue("wallet", sparkWalletId); command.Parameters.AddWithValue("maxSwapsInFlight", maxSwapsInFlight); diff --git a/test/NodeGuard.Tests/Jobs/AutoLiquidityManagementJobTests.cs b/test/NodeGuard.Tests/Jobs/AutoLiquidityManagementJobTests.cs index f277aa40..eccfbf23 100644 --- a/test/NodeGuard.Tests/Jobs/AutoLiquidityManagementJobTests.cs +++ b/test/NodeGuard.Tests/Jobs/AutoLiquidityManagementJobTests.cs @@ -116,7 +116,9 @@ private Node CreateTestNode( FortySwapMaxAmountSats = maxSwapSats, SparkSwapMinAmountSats = minSwapSats, SparkSwapMaxAmountSats = maxSwapSats, - MaxSwapsInFlight = maxSwapsInFlight, + LoopMaxSwapsInFlight = maxSwapsInFlight, + FortySwapMaxSwapsInFlight = maxSwapsInFlight, + SparkMaxSwapsInFlight = maxSwapsInFlight, MaxSwapRoutingFeeRatio = maxSwapFeeRatio, MinimumBalanceThresholdSats = minBalanceThresholdSats, SwapBudgetSats = swapBudgetSats, @@ -777,6 +779,46 @@ public async Task ManageNodeLiquidity_WhenTheExcessIsBelowEveryWeightedProviders It.IsAny()), Times.Never); } + [Fact] + public async Task ManageNodeLiquidity_AProviderAtItsMaxSwapsInFlight_IsLeftOut_AndAnotherSwaps() + { + // Loop runs one swap at a time and has one in flight; Spark runs up to five and has two + var (node, _) = ArrangeSwappableNode(loopWeight: 50, fortySwapWeight: 0, sparkWeight: 50); + node.LoopMaxSwapsInFlight = 1; + node.SparkMaxSwapsInFlight = 5; + _swapOutRepositoryMock.Setup(x => x.GetInFlightSwapsByNode(node.Id)).ReturnsAsync(new List + { + new() { Id = 1, Provider = SwapProvider.Loop, Status = SwapOutStatus.Pending }, + new() { Id = 2, Provider = SwapProvider.Spark, Status = SwapOutStatus.Pending }, + new() { Id = 3, Provider = SwapProvider.Spark, Status = SwapOutStatus.Pending } + }); + + var result = await _autoLiquidityManagementJob.ManageNodeLiquidity(node, CancellationToken.None); + + result.Should().Be(ManageNodeLiquidityResult.Success); + _swapsServiceMock.Verify(x => x.CreateSwapOutAsync(node, It.Is(s => s.Provider == SwapProvider.Spark), + It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ManageNodeLiquidity_WithEveryWeightedProviderAtItsMaxSwapsInFlight_SwapsNothing() + { + var (node, _) = ArrangeSwappableNode(loopWeight: 50, fortySwapWeight: 0, sparkWeight: 50); + node.LoopMaxSwapsInFlight = 1; + node.SparkMaxSwapsInFlight = 1; + _swapOutRepositoryMock.Setup(x => x.GetInFlightSwapsByNode(node.Id)).ReturnsAsync(new List + { + new() { Id = 1, Provider = SwapProvider.Loop, Status = SwapOutStatus.Pending }, + new() { Id = 2, Provider = SwapProvider.Spark, Status = SwapOutStatus.Pending } + }); + + var result = await _autoLiquidityManagementJob.ManageNodeLiquidity(node, CancellationToken.None); + + result.Should().Be(ManageNodeLiquidityResult.MaxSwapsInFlightReached); + _swapsServiceMock.Verify(x => x.CreateSwapOutAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + [Fact] public async Task ManageNodeLiquidity_WithAllWeightsAtZero_StillDefaultsToLoop() { diff --git a/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs index 8678d51a..5540bd4f 100644 --- a/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs +++ b/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs @@ -249,7 +249,7 @@ public async Task Create_RecordsTheSwap_ThenStartsItsPayment_WithoutWaitingForIt public async Task Create_APaidSwapWaitingInTheWallet_IsNotInFlight() { InvoiceIssued(); - _node.MaxSwapsInFlight = 1; + _node.SparkMaxSwapsInFlight = 1; _swapOuts.GetAllPending().Returns(new List { Pending(lightningFee: 12, leafIds: "l1", address: null) }); Balance(available: 0); @@ -374,7 +374,7 @@ public async Task Pay_WhenPayingThrows_LeavesItToTheMonitor() [InlineData(2, 2)] public async Task Create_AtTheNodesMaxSwapsInFlight_IsRefused(int maxSwapsInFlight, int inFlight) { - _node.MaxSwapsInFlight = maxSwapsInFlight; + _node.SparkMaxSwapsInFlight = maxSwapsInFlight; // Swaps being paid: a paid one waits in the wallet and is no longer in flight var pending = Enumerable.Range(10, inFlight).Select(id => Pending(id: id, lightningFee: null)).ToList(); pending.Add(new SwapOut { Id = 30, NodeId = 2, Provider = SwapProvider.Spark, Status = SwapOutStatus.Pending }); @@ -390,7 +390,7 @@ public async Task Create_AtTheNodesMaxSwapsInFlight_IsRefused(int maxSwapsInFlig [Fact] public async Task Create_BelowTheNodesMaxSwapsInFlight_IsAllowed() { - _node.MaxSwapsInFlight = 2; + _node.SparkMaxSwapsInFlight = 2; _swapOuts.GetAllPending().Returns(new List { Pending() }); InvoiceIssued(); NodePays(Payment.Types.PaymentStatus.Succeeded); @@ -437,7 +437,7 @@ public async Task Create_UsesTheWalletsOwnMaxBalance() public async Task Create_CountsWhatUnpaidSwapsWillBringIn_AgainstTheCap() { // 9M held + an unpaid 0.6M swap of the wallet still to come + 0.5M > the wallet's 10M max balance - _node.MaxSwapsInFlight = 2; + _node.SparkMaxSwapsInFlight = 2; _spark.GetBalanceAsync(W, Arg.Any()) .Returns(new WalletBalance(new SatsBalance(9_000_000, 9_000_000, 0), [], [])); var unpaid = Pending(lightningFee: null);