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