From afc9d4a480ed3c3a8270da69ec048ac95d17e17c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:01:27 +0200 Subject: [PATCH 1/6] Spark payment timeout in minutes, per node; rename the max parts setting A Spark swap's payment had a fixed 2-minute LND timeout. It is now SPARK_PAYMENT_TIMEOUT_MINUTES (10 by default, 1 to 25 so LND gives up well before the 30-minute invoice expires), and each node can set its own in its liquidity settings. MPP splitting stays the rebalances' one, whose setting is renamed SEND_PAYMENT_MAX_PARTS as every SendPaymentV2 call uses it (REBALANCE_MAX_PARTS is still read). --- docs/rebalance-algorithm.md | 2 +- src/Data/Models/Node.cs | 6 + src/Helpers/Constants.cs | 12 +- ...918_AddNodeSparkPaymentTimeout.Designer.cs | 2003 +++++++++++++++++ ...261013085918_AddNodeSparkPaymentTimeout.cs | 28 + .../ApplicationDbContextModelSnapshot.cs | 3 + src/Pages/Nodes.razor | 18 + src/Services/LightningService.cs | 2 +- src/Services/Spark/SparkSettings.cs | 26 + src/Services/Spark/SparkSwapService.cs | 3 +- .../Services/Spark/SparkSettingsTests.cs | 15 + .../Services/Spark/SparkSwapServiceTests.cs | 2 +- 12 files changed, 2110 insertions(+), 10 deletions(-) create mode 100644 src/Migrations/20261013085918_AddNodeSparkPaymentTimeout.Designer.cs create mode 100644 src/Migrations/20261013085918_AddNodeSparkPaymentTimeout.cs diff --git a/docs/rebalance-algorithm.md b/docs/rebalance-algorithm.md index 21715049..ec4f5a10 100644 --- a/docs/rebalance-algorithm.md +++ b/docs/rebalance-algorithm.md @@ -340,7 +340,7 @@ which one gets drained can differ between runs on an otherwise unchanged node. | `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. | -| `REBALANCE_MAX_PARTS` | `32` | Max MPP shards. | +| `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. | | `REBALANCE_RECONCILE_TERMINAL_WINDOW_HOURS` | `24` | How far back `MonitorRebalancesJob` reconciles. | diff --git a/src/Data/Models/Node.cs b/src/Data/Models/Node.cs index 1614b5ba..9fd8a4ac 100644 --- a/src/Data/Models/Node.cs +++ b/src/Data/Models/Node.cs @@ -145,6 +145,12 @@ public class Node : Entity /// public int? SparkWalletId { get; set; } + /// + /// How long LND keeps trying routes for this node's Spark swap payments, in minutes; null means + /// SPARK_PAYMENT_TIMEOUT_MINUTES + /// + public int? SparkPaymentTimeoutMinutes { get; set; } + #endregion Automatic Swap Out Configuration #region Routing Engine diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index 91344a98..5673f35b 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -199,10 +199,11 @@ public class Constants public static double REBALANCE_AMOUNT_BACKOFF_RATIO = 0.8; /// - /// Maximum number of partial payments (MPP shards) LND may split a rebalance into. 1 - /// disables splitting; higher values let LND complete an amount across several routes. + /// Maximum number of partial payments (MPP shards) LND may split a payment sent with SendPaymentV2 into: + /// rebalances and Spark swap payments (SEND_PAYMENT_MAX_PARTS; REBALANCE_MAX_PARTS still read as a + /// fallback). 1 disables splitting; higher values let LND complete an amount across several routes. /// - public static uint REBALANCE_MAX_PARTS = 32; + public static uint SEND_PAYMENT_MAX_PARTS = 32; /// /// How far back the MonitorRebalancesJob sweeps for already-terminal-but-possibly-wrong @@ -608,8 +609,9 @@ static Constants() var rebProbeBackoff = Environment.GetEnvironmentVariable("REBALANCE_AMOUNT_BACKOFF_RATIO"); if (rebProbeBackoff != null) REBALANCE_AMOUNT_BACKOFF_RATIO = double.Parse(rebProbeBackoff, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture); - var rebMaxParts = Environment.GetEnvironmentVariable("REBALANCE_MAX_PARTS"); - if (rebMaxParts != null) REBALANCE_MAX_PARTS = uint.Parse(rebMaxParts); + var sendPaymentMaxParts = Environment.GetEnvironmentVariable("SEND_PAYMENT_MAX_PARTS") + ?? Environment.GetEnvironmentVariable("REBALANCE_MAX_PARTS"); + if (sendPaymentMaxParts != null) SEND_PAYMENT_MAX_PARTS = uint.Parse(sendPaymentMaxParts); var rebReconcileWindow = Environment.GetEnvironmentVariable("REBALANCE_RECONCILE_TERMINAL_WINDOW_HOURS"); if (rebReconcileWindow != null) REBALANCE_RECONCILE_TERMINAL_WINDOW_HOURS = int.Parse(rebReconcileWindow); diff --git a/src/Migrations/20261013085918_AddNodeSparkPaymentTimeout.Designer.cs b/src/Migrations/20261013085918_AddNodeSparkPaymentTimeout.Designer.cs new file mode 100644 index 00000000..2e9d7ddd --- /dev/null +++ b/src/Migrations/20261013085918_AddNodeSparkPaymentTimeout.Designer.cs @@ -0,0 +1,2003 @@ +// +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("20261013085918_AddNodeSparkPaymentTimeout")] + partial class AddNodeSparkPaymentTimeout + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationUserNode", b => + { + b.Property("NodesId") + .HasColumnType("integer"); + + b.Property("UsersId") + .HasColumnType("text"); + + b.HasKey("NodesId", "UsersId"); + + b.HasIndex("UsersId"); + + b.ToTable("ApplicationUserNode"); + }); + + modelBuilder.Entity("ChannelOperationRequestFMUTXO", b => + { + b.Property("ChannelOperationRequestsId") + .HasColumnType("integer"); + + b.Property("UtxosId") + .HasColumnType("integer"); + + b.HasKey("ChannelOperationRequestsId", "UtxosId"); + + b.HasIndex("UtxosId"); + + b.ToTable("ChannelOperationRequestFMUTXO"); + }); + + modelBuilder.Entity("FMUTXOWalletWithdrawalRequest", b => + { + b.Property("UTXOsId") + .HasColumnType("integer"); + + b.Property("WalletWithdrawalRequestsId") + .HasColumnType("integer"); + + b.HasKey("UTXOsId", "WalletWithdrawalRequestsId"); + + b.HasIndex("WalletWithdrawalRequestsId"); + + b.ToTable("FMUTXOWalletWithdrawalRequest"); + }); + + modelBuilder.Entity("KeyWallet", b => + { + b.Property("KeysId") + .HasColumnType("integer"); + + b.Property("WalletsId") + .HasColumnType("integer"); + + b.HasKey("KeysId", "WalletsId"); + + b.HasIndex("WalletsId"); + + b.ToTable("KeyWallet"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(21) + .HasColumnType("character varying(21)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + + b.HasDiscriminator().HasValue("IdentityUser"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.APIToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatorId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp without time zone"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatorId"); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActionType") + .HasColumnType("integer"); + + b.Property("Details") + .HasColumnType("text"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("ObjectAffected") + .HasColumnType("integer"); + + b.Property("ObjectId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Username") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BtcCloseAddress") + .HasColumnType("text"); + + b.Property("ChanId") + .HasColumnType("numeric(20,0)"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByNodeGuard") + .HasColumnType("boolean"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DestinationNodeId") + .HasColumnType("integer"); + + b.Property("FundingTx") + .IsRequired() + .HasColumnType("text"); + + b.Property("FundingTxOutputIndex") + .HasColumnType("bigint"); + + b.Property("IsAutoRebalanceEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsAutomatedLiquidityEnabled") + .HasColumnType("boolean"); + + b.Property("IsDynamicFeeEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("IsPrivate") + .HasColumnType("boolean"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceNodeId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DestinationNodeId"); + + b.HasIndex("SourceNodeId"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelFeeState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAppliedInboundBaseMsat") + .HasColumnType("integer"); + + b.Property("LastAppliedInboundPpm") + .HasColumnType("integer"); + + b.Property("LastAppliedOutboundBaseFeeMsat") + .HasColumnType("bigint"); + + b.Property("LastAppliedOutboundPpm") + .HasColumnType("bigint"); + + b.Property("LastComputedTarget") + .HasColumnType("double precision"); + + b.Property("LastFeeUpdateAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastObservedRatio") + .HasColumnType("double precision"); + + b.Property("ManagedNodePubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ManagedNodePubKey") + .IsUnique(); + + b.ToTable("ChannelFeeStates"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountCryptoUnit") + .HasColumnType("integer"); + + b.Property("Changeless") + .HasColumnType("boolean"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("ClosingReason") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DestNodeId") + .HasColumnType("integer"); + + b.Property("FeeRate") + .HasColumnType("numeric"); + + b.Property("InitialChannelBaseFeeMsat") + .HasColumnType("bigint"); + + b.Property("InitialChannelFeeRatePpm") + .HasColumnType("bigint"); + + b.Property("IsChannelPrivate") + .HasColumnType("boolean"); + + b.Property("MempoolRecommendedFeesType") + .HasColumnType("integer"); + + b.Property("RequestType") + .HasColumnType("integer"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceNodeId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property>("StatusLogs") + .HasColumnType("jsonb"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("WalletId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("DestNodeId"); + + b.HasIndex("SourceNodeId"); + + b.HasIndex("UserId"); + + b.HasIndex("WalletId"); + + b.ToTable("ChannelOperationRequests"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequestPSBT", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelOperationRequestId") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsFinalisedPSBT") + .HasColumnType("boolean"); + + b.Property("IsInternalWalletPSBT") + .HasColumnType("boolean"); + + b.Property("IsTemplatePSBT") + .HasColumnType("boolean"); + + b.Property("PSBT") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserSignerId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ChannelOperationRequestId"); + + b.HasIndex("UserSignerId"); + + b.HasIndex(new[] { "ChannelOperationRequestId" }, "IX_ChannelOperationRequestPSBTs_Template") + .IsUnique() + .HasFilter("\"IsTemplatePSBT\""); + + b.ToTable("ChannelOperationRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelRoutingState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AgeBlocks") + .HasColumnType("bigint"); + + b.Property("ChanIdLnd") + .HasColumnType("numeric(20,0)"); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("ConsecutiveCategoryCyclesInNewState") + .HasColumnType("bigint"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("EmaLocalRatio") + .HasColumnType("double precision"); + + b.Property("FundingBlockHeight") + .HasColumnType("bigint"); + + b.Property("LastCategorizedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastEvaluatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastKnownLifetime") + .HasColumnType("bigint"); + + b.Property("LastKnownNumUpdates") + .HasColumnType("bigint"); + + b.Property("LastKnownUptime") + .HasColumnType("bigint"); + + b.Property("ManagedNodePubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("NetFlowRatio") + .HasColumnType("double precision"); + + b.Property("PeerFlowCategory") + .HasColumnType("integer"); + + b.Property("PeerInitiated") + .HasColumnType("boolean"); + + b.Property("PendingCategory") + .HasColumnType("integer"); + + b.Property("PullMsatWindow") + .HasColumnType("bigint"); + + b.Property("PushMsatWindow") + .HasColumnType("bigint"); + + b.Property("TargetLocalRatio") + .HasColumnType("double precision"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ManagedNodePubKey") + .IsUnique(); + + b.ToTable("ChannelRoutingStates"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.FMUTXO", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("OutputIndex") + .HasColumnType("bigint"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("TxId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("FMUTXOs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ForwardingHtlcEvent", b => + { + b.Property("ManagedNodePubKey") + .HasColumnType("text"); + + b.Property("IncomingChannelId") + .HasColumnType("numeric(20,0)"); + + b.Property("OutgoingChannelId") + .HasColumnType("numeric(20,0)"); + + b.Property("IncomingHtlcId") + .HasColumnType("numeric(20,0)"); + + b.Property("OutgoingHtlcId") + .HasColumnType("numeric(20,0)"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("EventCase") + .HasColumnType("integer"); + + b.Property("EventTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("FailureDetail") + .HasColumnType("integer"); + + b.Property("FailureString") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("FeeMsat") + .HasColumnType("bigint"); + + b.Property("GrossFeeMsat") + .HasColumnType("bigint"); + + b.Property("InboundFeeMsat") + .HasColumnType("bigint"); + + b.Property("InboundFeePpm") + .HasColumnType("bigint"); + + b.Property("IncomingAmountMsat") + .HasColumnType("numeric(20,0)"); + + b.Property("IncomingPeerAlias") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IncomingTimelock") + .HasColumnType("bigint"); + + b.Property("ManagedNodeName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Outcome") + .HasColumnType("integer"); + + b.Property("OutgoingAmountMsat") + .HasColumnType("numeric(20,0)"); + + b.Property("OutgoingPeerAlias") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OutgoingTimelock") + .HasColumnType("bigint"); + + b.Property("RoutingFeePpm") + .HasColumnType("bigint"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WireFailureCode") + .HasColumnType("integer"); + + b.HasKey("ManagedNodePubKey", "IncomingChannelId", "OutgoingChannelId", "IncomingHtlcId", "OutgoingHtlcId"); + + b.HasIndex("CreationDatetime"); + + b.HasIndex("EventTimestamp"); + + b.ToTable("ForwardingHtlcEvents"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.InternalWallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DerivationPath") + .IsRequired() + .HasColumnType("text"); + + b.Property("MasterFingerprint") + .HasColumnType("text"); + + b.Property("MnemonicString") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("XPUB") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("InternalWallets"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Key", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("InternalWalletId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsBIP39ImportedKey") + .HasColumnType("boolean"); + + b.Property("IsCompromised") + .HasColumnType("boolean"); + + b.Property("MasterFingerprint") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Path") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("XPUB") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InternalWalletId"); + + b.HasIndex("UserId"); + + b.ToTable("Keys"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.LiquidityRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChannelId") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("IsReverseSwapWalletRule") + .HasColumnType("boolean"); + + b.Property("MinimumLocalBalance") + .HasColumnType("numeric"); + + b.Property("MinimumRemoteBalance") + .HasColumnType("numeric"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("RebalanceTarget") + .HasColumnType("numeric"); + + b.Property("ReverseSwapAddress") + .HasColumnType("text"); + + b.Property("ReverseSwapWalletId") + .HasColumnType("integer"); + + b.Property("SwapWalletId") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId") + .IsUnique(); + + b.HasIndex("NodeId"); + + b.HasIndex("ReverseSwapWalletId"); + + b.HasIndex("SwapWalletId"); + + b.ToTable("LiquidityRules"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowPositiveInboundFees") + .HasColumnType("boolean"); + + b.Property("AutoLiquidityManagementEnabled") + .HasColumnType("boolean"); + + b.Property("AutoRebalanceEnabled") + .HasColumnType("boolean"); + + b.Property("AutosweepEnabled") + .HasColumnType("boolean"); + + b.Property("ChannelAdminMacaroon") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DynamicFeeManagementEnabled") + .HasColumnType("boolean"); + + b.Property("Endpoint") + .HasColumnType("text"); + + b.Property("FortySwapEndpoint") + .HasColumnType("text"); + + b.Property("FortySwapWeight") + .HasColumnType("integer"); + + b.Property("FundsDestinationWalletId") + .HasColumnType("integer"); + + b.Property("IsNodeDisabled") + .HasColumnType("boolean"); + + b.Property("LoopSwapWeight") + .HasColumnType("integer"); + + b.Property("LoopdCert") + .HasColumnType("text"); + + b.Property("LoopdEndpoint") + .HasColumnType("text"); + + b.Property("LoopdMacaroon") + .HasColumnType("text"); + + b.Property("MaxRebalanceCostToEarnRatio") + .HasColumnType("double precision"); + + b.Property("MaxRebalancesInFlight") + .HasColumnType("integer"); + + b.Property("MaxSwapRoutingFeeRatio") + .HasColumnType("numeric"); + + b.Property("MaxSwapsInFlight") + .HasColumnType("integer"); + + b.Property("MinimumBalanceThresholdSats") + .HasColumnType("bigint"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("PubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("RebalanceBudgetRefreshInterval") + .HasColumnType("interval"); + + b.Property("RebalanceBudgetSats") + .HasColumnType("bigint"); + + b.Property("RebalanceBudgetStartDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("RoutingEngineDryRun") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("SparkPaymentTimeoutMinutes") + .HasColumnType("integer"); + + 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("SwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("SwapMinAmountSats") + .HasColumnType("bigint"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FundsDestinationWalletId"); + + b.HasIndex("PubKey") + .IsUnique(); + + b.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/20261013085918_AddNodeSparkPaymentTimeout.cs b/src/Migrations/20261013085918_AddNodeSparkPaymentTimeout.cs new file mode 100644 index 00000000..9674adf5 --- /dev/null +++ b/src/Migrations/20261013085918_AddNodeSparkPaymentTimeout.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NodeGuard.Migrations +{ + /// + public partial class AddNodeSparkPaymentTimeout : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SparkPaymentTimeoutMinutes", + table: "Nodes", + type: "integer", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SparkPaymentTimeoutMinutes", + table: "Nodes"); + } + } +} diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index 2f7cb187..f13ebf08 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1069,6 +1069,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("boolean") .HasDefaultValue(false); + b.Property("SparkPaymentTimeoutMinutes") + .HasColumnType("integer"); + b.Property("SparkSwapWeight") .HasColumnType("integer"); diff --git a/src/Pages/Nodes.razor b/src/Pages/Nodes.razor index 2bdab1cd..d65091ab 100644 --- a/src/Pages/Nodes.razor +++ b/src/Pages/Nodes.razor @@ -359,6 +359,18 @@ } + + + Spark Payment Timeout + + How long LND keeps trying routes for this node's Spark swap payments, in minutes + (1-@SparkSettings.MaxPaymentTimeoutMinutes; empty: @SparkSettings.PaymentTimeoutMinutes, SPARK_PAYMENT_TIMEOUT_MINUTES) + + + + } @@ -1059,6 +1071,12 @@ return; } + if (_selectedNodeForLiquidity.SparkPaymentTimeoutMinutes is < 1 || _selectedNodeForLiquidity.SparkPaymentTimeoutMinutes > SparkSettings.MaxPaymentTimeoutMinutes) + { + ToastService.ShowError($"The Spark payment timeout must be 1 to {SparkSettings.MaxPaymentTimeoutMinutes} minutes, or empty for the default"); + return; + } + // 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; diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs index a382f665..112c037b 100644 --- a/src/Services/LightningService.cs +++ b/src/Services/LightningService.cs @@ -1713,7 +1713,7 @@ public async Task SendPaymentV2Async(Node node, string paymentRequest, FeeLimitMsat = feeLimitMsat, TimeoutSeconds = timeoutSeconds > 0 ? timeoutSeconds : Constants.DEFAULT_REBALANCE_TIMEOUT_SECONDS, AllowSelfPayment = true, // mandatory for circular self-pay - MaxParts = Constants.REBALANCE_MAX_PARTS, // let LND split (MPP) across routes + MaxParts = Constants.SEND_PAYMENT_MAX_PARTS, // let LND split (MPP) across routes NoInflightUpdates = true, // we only care about the terminal Payment update }; diff --git a/src/Services/Spark/SparkSettings.cs b/src/Services/Spark/SparkSettings.cs index 88f449f8..23a74cf9 100644 --- a/src/Services/Spark/SparkSettings.cs +++ b/src/Services/Spark/SparkSettings.cs @@ -55,6 +55,15 @@ public readonly record struct SparkWalletRef(int? WalletId) public sealed class SparkSettings { public const long DefaultMaxExitFeeSats = 20_000; + + /// How long LND keeps trying routes for a Spark swap's payment, by default. + public const int DefaultPaymentTimeoutMinutes = 10; + + /// + /// The longest payment timeout: LND must stop trying routes well before the 30-minute invoice + /// () expires. + /// + public const int MaxPaymentTimeoutMinutes = 25; public const long DefaultMaxBalanceSats = 10_000_000; public static readonly SparkSettings Disabled = new(); @@ -105,6 +114,15 @@ public sealed class SparkSettings /// How often paid Spark swaps exit on-chain, in minutes: or its default. public int ExitIntervalOrDefault(bool isDevEnvironment) => ExitIntervalMinutes ?? (isDevEnvironment ? 1 : 10); + /// + /// How long LND keeps trying routes for a Spark swap's payment, in minutes (SPARK_PAYMENT_TIMEOUT_MINUTES, 1 to + /// ), unless the node has its own. + /// + public int PaymentTimeoutMinutes { get; init; } = DefaultPaymentTimeoutMinutes; + + /// The payment timeout for 's Spark swaps: its own, else . + public int PaymentTimeoutFor(Node node) => node.SparkPaymentTimeoutMinutes ?? PaymentTimeoutMinutes; + /// /// Most sats a Spark wallet may hold, counting a new swap (SPARK_MAX_BALANCE_SATS), unless the wallet has its /// own: Spark balances are only meant to pass through. @@ -254,6 +272,13 @@ public static SparkSettings FromEnvironment(Func env, Network n else errors.Add("SPARK_EXIT_INTERVAL_MINUTES must be a positive number of minutes"); } + var paymentTimeout = DefaultPaymentTimeoutMinutes; + if (env("SPARK_PAYMENT_TIMEOUT_MINUTES") is { Length: > 0 } paymentTimeoutValue && + (!int.TryParse(paymentTimeoutValue, out paymentTimeout) || paymentTimeout is < 1 or > MaxPaymentTimeoutMinutes)) + { + errors.Add($"SPARK_PAYMENT_TIMEOUT_MINUTES must be a whole number of minutes from 1 to {MaxPaymentTimeoutMinutes}"); + } + var maxBalance = DefaultMaxBalanceSats; if (env("SPARK_MAX_BALANCE_SATS") is { Length: > 0 } maxBalanceValue && (!long.TryParse(maxBalanceValue, out maxBalance) || maxBalance <= 0)) @@ -298,6 +323,7 @@ public static SparkSettings FromEnvironment(Func env, Network n IdentityPublicKey = identity?.Trim().ToLowerInvariant(), MaxExitFeeSats = maxExitFee, ExitIntervalMinutes = exitInterval, + PaymentTimeoutMinutes = paymentTimeout, MaxBalanceSats = maxBalance, SignerEndpoint = endpoint, SignerRieUrl = rieUrl diff --git a/src/Services/Spark/SparkSwapService.cs b/src/Services/Spark/SparkSwapService.cs index 0803b280..2dd8e291 100644 --- a/src/Services/Spark/SparkSwapService.cs +++ b/src/Services/Spark/SparkSwapService.cs @@ -99,7 +99,6 @@ public sealed class SparkSwapService : ISparkSwapService /// the swap's routing fee, so it is shown but never added to the swap's fees. /// public const int ServiceFeeBps = 15; - private const int PaymentTimeoutSeconds = 120; private const decimal DefaultMaxRoutingFeePercent = 0.5m; /// The status of a transfer this wallet has claimed: its leaves are the wallet's. private const string ClaimedTransferStatus = "Completed"; @@ -230,7 +229,7 @@ public async Task CreateSwapOutAsync(Node node, SwapOut swapOut var maxFeePercent = request.MaxRoutingFeesPercent ?? DefaultMaxRoutingFeePercent; var feeLimitMsat = (long)(request.Amount * 1000m * maxFeePercent / 100m); var payment = await _lightningService.SendPaymentV2Async(node, paymentRequest, 0, feeLimitMsat, - request.ChannelsOut, null, PaymentTimeoutSeconds, CancellationToken.None); + request.ChannelsOut, null, _settings.PaymentTimeoutFor(node) * 60, CancellationToken.None); if (payment.Status == Payment.Types.PaymentStatus.Succeeded) { diff --git a/test/NodeGuard.Tests/Services/Spark/SparkSettingsTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkSettingsTests.cs index aaed9ffb..29d60bc5 100644 --- a/test/NodeGuard.Tests/Services/Spark/SparkSettingsTests.cs +++ b/test/NodeGuard.Tests/Services/Spark/SparkSettingsTests.cs @@ -102,6 +102,16 @@ public void ANode_SwapsThroughItsSparkWallet_OrTheRemoteSignersWallet() SparkSettings.Disabled.WalletFor(new Node { SparkWalletId = 4 }).Should().BeNull(); } + [Fact] + public void ANodesOwnPaymentTimeout_OverridesTheDefaultOfTenMinutes() + { + var settings = Read(Remote()); + + settings.PaymentTimeoutMinutes.Should().Be(10); + settings.PaymentTimeoutFor(new Node()).Should().Be(10); + settings.PaymentTimeoutFor(new Node { SparkPaymentTimeoutMinutes = 4 }).Should().Be(4); + } + [Fact] public void AWalletsOwnMaxBalance_OverridesTheDefault() { @@ -152,6 +162,7 @@ public void OnRegtest_TheConfiguredNetworkIsRead() env["SPARK_SIGNER_RIE_URL"] = "http://spark-signer:8080/2015-03-31/functions/function/invocations"; env["SPARK_MAX_EXIT_FEE_SATS"] = "5000"; env["SPARK_EXIT_INTERVAL_MINUTES"] = "3"; + env["SPARK_PAYMENT_TIMEOUT_MINUTES"] = "15"; env["SPARK_MAX_BALANCE_SATS"] = "2000000"; var settings = Read(env, Network.RegTest); @@ -164,6 +175,7 @@ public void OnRegtest_TheConfiguredNetworkIsRead() settings.SignerRieUrl!.Port.Should().Be(8080); settings.MaxExitFeeSats.Should().Be(5000); settings.ExitIntervalMinutes.Should().Be(3); + settings.PaymentTimeoutMinutes.Should().Be(15); settings.MaxBalanceSats.Should().Be(2_000_000); settings.ToSparkOptions(SspIdentity).EffectiveSspIdentityPublicKeyHex.Should().Be(SspIdentity); } @@ -183,6 +195,9 @@ public void OnRegtest_TheConfiguredNetworkIsRead() [InlineData("SPARK_MAX_EXIT_FEE_SATS", "0", "SPARK_MAX_EXIT_FEE_SATS")] [InlineData("SPARK_EXIT_INTERVAL_MINUTES", "0", "SPARK_EXIT_INTERVAL_MINUTES")] [InlineData("SPARK_EXIT_INTERVAL_MINUTES", "ten", "SPARK_EXIT_INTERVAL_MINUTES")] + [InlineData("SPARK_PAYMENT_TIMEOUT_MINUTES", "0", "SPARK_PAYMENT_TIMEOUT_MINUTES must be a whole number of minutes from 1 to 25")] + [InlineData("SPARK_PAYMENT_TIMEOUT_MINUTES", "26", "SPARK_PAYMENT_TIMEOUT_MINUTES")] + [InlineData("SPARK_PAYMENT_TIMEOUT_MINUTES", "ten", "SPARK_PAYMENT_TIMEOUT_MINUTES")] [InlineData("SPARK_MAX_BALANCE_SATS", "-5", "SPARK_MAX_BALANCE_SATS")] public void InvalidSettings_FailTheStartup(string variable, string? value, string expected) { diff --git a/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs index 358975b9..ec7831bd 100644 --- a/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs +++ b/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs @@ -198,7 +198,7 @@ public async Task Create_RecordsTheSwapBeforePayingTheInvoice() NSubstitute.Received.InOrder(() => { _swapOuts.AddAsync(swapOut); - _lightning.SendPaymentV2Async(_node, Invoice, 0, 5_000_000, null, null, Arg.Any(), CancellationToken.None); + _lightning.SendPaymentV2Async(_node, Invoice, 0, 5_000_000, null, null, 600, CancellationToken.None); _swapOuts.Update(swapOut); }); swapOut.ProviderId.Should().Be("req-1"); From 440186bceea1a368626c8f9746a563ecb3576df2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:05:42 +0200 Subject: [PATCH 2/6] Spark swaps: creation doesn't wait for the payment With a payment timeout of minutes, creating a Spark swap would block the New Swap dialog and RequestSwapOut until LND gave up. The swap is now recorded and returned pending, and its payment runs in the background (SparkPaymentLauncher, in a scope of its own) and saves its outcome: the routing fee once paid, the failure once it definitely failed. An unknown outcome, or NodeGuard restarting mid-payment, is left to MonitorSwapsJob, which finds the payment by its hash. The dialog closes onto the Swaps page. --- docs/spark-swaps.md | 3 +- src/Program.cs | 1 + src/Proto/nodeguard.proto | 6 +- src/Services/Spark/SparkPaymentLauncher.cs | 67 ++++++++++++++ src/Services/Spark/SparkSwapService.cs | 61 ++++++++---- src/Shared/NewSwapModal.razor | 10 +- .../Spark/SparkPaymentLauncherTests.cs | 70 ++++++++++++++ .../Services/Spark/SparkSwapServiceTests.cs | 92 ++++++++++++------- 8 files changed, 257 insertions(+), 53 deletions(-) create mode 100644 src/Services/Spark/SparkPaymentLauncher.cs create mode 100644 test/NodeGuard.Tests/Services/Spark/SparkPaymentLauncherTests.cs diff --git a/docs/spark-swaps.md b/docs/spark-swaps.md index cadbaa47..cf73be8b 100644 --- a/docs/spark-swaps.md +++ b/docs/spark-swaps.md @@ -15,7 +15,7 @@ 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. The payment is not cancellable. A definitive failure fails the swap. An unknown outcome, such as a broken payment stream, is left to the monitor. +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 invoice has expired. Once the payment has settled, the swap is `SparkSwapExitJob`'s. @@ -86,6 +86,7 @@ While Spark is enabled, invalid settings stop NodeGuard at startup. | `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_EXIT_INTERVAL_MINUTES` | `10`, `1` in a dev environment | How often paid swaps exit on-chain | +| `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 | | `SPARK_OPERATOR_CERTS_DIR` | none; refused on mainnet | Self-signed operator certificates to pin (regtest) | | `SPARK_SIGNER_RIE_URL` | none; refused on mainnet | Reach a locally run signer image through the Lambda emulator | diff --git a/src/Program.cs b/src/Program.cs index 1bd8df7f..7557a6f5 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -165,6 +165,7 @@ public static async Task Main(string[] args) () => new ImmutableCredentials(Constants.AWS_ACCESS_KEY_ID, Constants.AWS_SECRET_ACCESS_KEY, null), Constants.AWS_REGION)); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/src/Proto/nodeguard.proto b/src/Proto/nodeguard.proto index b34e7ff9..37cef55f 100644 --- a/src/Proto/nodeguard.proto +++ b/src/Proto/nodeguard.proto @@ -133,8 +133,10 @@ service NodeGuardService { /* Requests a manual swap-out of a node's Lightning liquidity to a NodeGuard wallet, as the New Swap dialog does. - An unused deposit address of the wallet is reserved for the payout. With reference_id, a repeated request - returns the swap already created for it instead of creating another one. + For Loop and 40swap, an unused deposit address of the wallet is reserved for the payout; a Spark swap reserves + its own when it exits, and returns pending as soon as it is recorded: its Lightning payment runs in the + background (follow it with GetSwapOut). With reference_id, a repeated request returns the swap already created + for it instead of creating another one. */ rpc RequestSwapOut(RequestSwapOutRequest) returns (RequestSwapOutResponse); diff --git a/src/Services/Spark/SparkPaymentLauncher.cs b/src/Services/Spark/SparkPaymentLauncher.cs new file mode 100644 index 00000000..898a1334 --- /dev/null +++ b/src/Services/Spark/SparkPaymentLauncher.cs @@ -0,0 +1,67 @@ +/* + * 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; + +namespace NodeGuard.Services.Spark; + +/// A recorded Spark swap's Lightning payment, as its creation set it up. +/// How long LND keeps trying routes (the node's Spark payment timeout). +public sealed record SparkPayment(Node Node, int SwapId, string PaymentRequest, long FeeLimitMsat, ulong[]? ChannelsOut, + int TimeoutSeconds); + +/// +/// Pays recorded Spark swaps in the background, so creating one doesn't wait for its payment: the swap shows as +/// pending until the payment's outcome is saved, here or by MonitorSwapsJob. +/// +public interface ISparkPaymentLauncher +{ + void Launch(SparkPayment payment); +} + +public sealed class SparkPaymentLauncher : ISparkPaymentLauncher +{ + private readonly IServiceScopeFactory _scopes; + private readonly ILogger _logger; + + public SparkPaymentLauncher(IServiceScopeFactory scopes, ILogger logger) + { + _scopes = scopes; + _logger = logger; + } + + public void Launch(SparkPayment payment) => _ = Task.Run(() => PayAsync(payment)); + + /// + /// Pays in a scope of its own, as the request that created the swap may be over. A failure here is only + /// logged: MonitorSwapsJob finds the payment by its hash, and fails the swap once its invoice has expired. + /// + internal async Task PayAsync(SparkPayment payment) + { + try + { + using var scope = _scopes.CreateScope(); + await scope.ServiceProvider.GetRequiredService().PayAsync(payment); + } + catch (Exception e) + { + _logger.LogError(e, "Spark swap {SwapId}: its payment could not be followed; MonitorSwapsJob takes over", payment.SwapId); + } + } +} diff --git a/src/Services/Spark/SparkSwapService.cs b/src/Services/Spark/SparkSwapService.cs index 2dd8e291..65c0b324 100644 --- a/src/Services/Spark/SparkSwapService.cs +++ b/src/Services/Spark/SparkSwapService.cs @@ -63,13 +63,23 @@ public enum SparkExitStep /// public interface ISparkSwapService { - /// Creates, records and pays a Spark swap; see . + /// + /// Creates and records a Spark swap, then starts its payment without waiting for it (see + /// ): the swap is returned pending. See . + /// /// Spark is disabled or unavailable. /// /// The node has no Spark wallet or already has its Max swaps in flight of Spark swaps, or its wallet has no room. /// Task CreateSwapOutAsync(Node node, SwapOut swapOut, SwapOutRequest request, CancellationToken ct = default); + /// + /// Pays a recorded swap's invoice and saves the outcome: the routing fee once paid, the failure once it + /// definitely failed. An unknown outcome is left to MonitorSwapsJob. Not cancellable: once the payment starts + /// it is followed to its end. + /// + Task PayAsync(SparkPayment payment); + /// /// 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. @@ -124,11 +134,12 @@ public sealed class SparkSwapService : ISparkSwapService private readonly ISwapOutRepository _swapOutRepository; private readonly IWalletRepository _walletRepository; private readonly INBXplorerService _nbXplorerService; + private readonly ISparkPaymentLauncher _payments; private readonly TimeProvider _time; public SparkSwapService(ILogger logger, SparkSettings settings, ISparkWalletService spark, ILightningService lightningService, ISwapOutRepository swapOutRepository, IWalletRepository walletRepository, - INBXplorerService nbXplorerService, TimeProvider time) + INBXplorerService nbXplorerService, ISparkPaymentLauncher payments, TimeProvider time) { _logger = logger; _settings = settings; @@ -137,6 +148,7 @@ public SparkSwapService(ILogger logger, SparkSettings settings _swapOutRepository = swapOutRepository; _walletRepository = walletRepository; _nbXplorerService = nbXplorerService; + _payments = payments; _time = time; } @@ -225,35 +237,52 @@ public async Task CreateSwapOutAsync(Node node, SwapOut swapOut Reservation.Release(); } - // Not cancellable: once the payment starts it must be followed to its end (or left to the monitor) + // Paid in the background, for up to the node's payment timeout: the swap shows as pending meanwhile var maxFeePercent = request.MaxRoutingFeesPercent ?? DefaultMaxRoutingFeePercent; var feeLimitMsat = (long)(request.Amount * 1000m * maxFeePercent / 100m); - var payment = await _lightningService.SendPaymentV2Async(node, paymentRequest, 0, feeLimitMsat, - request.ChannelsOut, null, _settings.PaymentTimeoutFor(node) * 60, CancellationToken.None); + _payments.Launch(new SparkPayment(node, swapOut.Id, paymentRequest, feeLimitMsat, request.ChannelsOut, + _settings.PaymentTimeoutFor(node) * 60)); + _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); + } - if (payment.Status == Payment.Types.PaymentStatus.Succeeded) + public async Task PayAsync(SparkPayment payment) + { + if (await _swapOutRepository.GetById(payment.SwapId) is not { Status: SwapOutStatus.Pending, LightningFeeSats: null } swap) + { + _logger.LogWarning("Spark swap {SwapId}: no longer waits for its payment, which is not sent", payment.SwapId); + return; + } + + // Not cancellable: once the payment starts it must be followed to its end (or left to the monitor) + var node = payment.Node; + var result = await _lightningService.SendPaymentV2Async(node, payment.PaymentRequest, 0, payment.FeeLimitMsat, + payment.ChannelsOut, null, payment.TimeoutSeconds, CancellationToken.None); + + if (result.Status == Payment.Types.PaymentStatus.Succeeded) { - swapOut.LightningFeeSats = payment.FeeSat; + swap.LightningFeeSats = result.FeeSat; _logger.LogInformation("Spark swap {SwapId}: {Node} paid {Amount} sats into Spark (routing fee {Fee} sats)", - swapOut.Id, node.Name, request.Amount, payment.FeeSat); + swap.Id, node.Name, swap.SatsAmount, result.FeeSat); } - else if (payment.Status == Payment.Types.PaymentStatus.Failed && - payment.FailureReason != PaymentFailureReason.FailureReasonError) + else if (result.Status == Payment.Types.PaymentStatus.Failed && + result.FailureReason != PaymentFailureReason.FailureReasonError) { - swapOut.Status = SwapOutStatus.Failed; - swapOut.ErrorDetails = $"Lightning payment failed: {payment.FailureReason}"; + swap.Status = SwapOutStatus.Failed; + swap.ErrorDetails = $"Lightning payment failed: {result.FailureReason}"; _logger.LogWarning("Spark swap {SwapId}: the payment from {Node} failed: {Reason}", - swapOut.Id, node.Name, payment.FailureReason); + swap.Id, node.Name, result.FailureReason); } else { // Unknown outcome (a stream error is reported as FailureReasonError): the monitor looks the payment up _logger.LogWarning("Spark swap {SwapId}: the payment from {Node} has no final outcome yet ({Status}, {Reason})", - swapOut.Id, node.Name, payment.Status, payment.FailureReason); + swap.Id, node.Name, result.Status, result.FailureReason); + return; } - Save(swapOut); - return new SwapOutCreation(Response(swapOut), true, null); + Save(swap); } public async Task AdvanceSwapAsync(Node node, SwapOut swap, CancellationToken ct = default) diff --git a/src/Shared/NewSwapModal.razor b/src/Shared/NewSwapModal.razor index 44a33a34..48a22d46 100644 --- a/src/Shared/NewSwapModal.razor +++ b/src/Shared/NewSwapModal.razor @@ -365,9 +365,13 @@ if (response.Status == SwapOutStatus.Failed) { - // Spark pays while creating the swap: a failed payment fails the swap right away ToastService.ShowError($"The swap failed: {response.ErrorMessage}"); } + else if (_selectedProvider == SwapProvider.Spark) + { + // A Spark swap is paid in the background: its progress shows on the Swaps page + ToastService.ShowSuccess("Swap created: its payment is on its way. Follow it on the Swaps page"); + } else { ToastService.ShowSuccess("Swap created successfully"); @@ -529,7 +533,9 @@ $"
  • Service Fee: Spark's fixed {SparkSwapService.ServiceFeeBps} bps ({FeeFormat.Percent(SparkSwapService.ServiceFeeBps * 100L)}), charged on the Lightning route: part of the off-chain fee
  • " + $"
  • On-Chain Fee (from Spark to L1: the SSP's exit fee and the L1 broadcast): up to {FormatBtcWithUsd(maxExitFee.ToUnit(MoneyUnit.BTC))} ({PercentOfAmount(maxExitFee)})
  • " + "
    " + - $"The node pays its Spark wallet ({SparkIdentityOf(_selectedNode!)}) now. Once paid, the swap exits " + + $"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 " + $"on-chain to the destination wallet on its own, within about {SparkSettings.ExitIntervalOrDefault(Constants.IS_DEV_ENVIRONMENT)} minutes. " + $"Up to {maxSwapsInFlight} Spark swaps of {_selectedNode?.Name} (its Max swaps in flight) run at once.

    " + "Please confirm that you want to proceed."; diff --git a/test/NodeGuard.Tests/Services/Spark/SparkPaymentLauncherTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkPaymentLauncherTests.cs new file mode 100644 index 00000000..30ea6515 --- /dev/null +++ b/test/NodeGuard.Tests/Services/Spark/SparkPaymentLauncherTests.cs @@ -0,0 +1,70 @@ +/* + * 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 FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NodeGuard.Data.Models; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace NodeGuard.Services.Spark; + +public class SparkPaymentLauncherTests +{ + private readonly ISparkSwapService _swaps = Substitute.For(); + private readonly SparkPayment _payment = new(new Node { Id = 1, Name = "alice" }, 7, "lnbcrt5m1invoice", 5_000_000, null, 600); + + private SparkPaymentLauncher Launcher() => new( + new ServiceCollection().AddScoped(_ => _swaps).BuildServiceProvider().GetRequiredService(), + NullLogger.Instance); + + [Fact] + public async Task APayment_IsMadeByTheSwapServiceOfAScopeOfItsOwn() + { + await Launcher().PayAsync(_payment); + + await _swaps.Received(1).PayAsync(_payment); + } + + [Fact] + public async Task APaymentThatCouldNotBeFollowed_IsLeftToTheMonitor_WithoutThrowing() + { + _swaps.PayAsync(_payment).ThrowsAsync(new InvalidOperationException("LND is down")); + + var act = () => Launcher().PayAsync(_payment); + + await act.Should().NotThrowAsync(); + } + + [Fact] + public async Task Launch_PaysInTheBackground() + { + var paid = new TaskCompletionSource(); + _swaps.PayAsync(_payment).Returns(_ => + { + paid.SetResult(); + return Task.CompletedTask; + }); + + Launcher().Launch(_payment); + + await paid.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } +} diff --git a/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs index ec7831bd..a909f003 100644 --- a/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs +++ b/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs @@ -54,6 +54,7 @@ public class SparkSwapServiceTests private readonly ISwapOutRepository _swapOuts = Substitute.For(); private readonly IWalletRepository _wallets = Substitute.For(); private readonly INBXplorerService _nbXplorer = Substitute.For(); + private readonly ISparkPaymentLauncher _payments = 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; @@ -82,7 +83,9 @@ public SparkSwapServiceTests() } private SparkSwapService Service() => new(NullLogger.Instance, Settings, _spark, _lightning, _swapOuts, - _wallets, _nbXplorer, _time); + _wallets, _nbXplorer, _payments, _time); + + private SparkPayment ThePayment(int swapId = 7) => new(_node, swapId, Invoice, 5_000_000, null, 600); // No address: a Spark swap reserves its own when it exits private static SwapOutRequest Request() => new() { Amount = 500_000, MaxRoutingFeesPercent = 1m }; @@ -180,10 +183,9 @@ private static SwapOut Pending(long? lightningFee = 12, string? txId = null, str // ── Creation ───────────────────────────────────────────────────────────────────────── [Fact] - public async Task Create_RecordsTheSwapBeforePayingTheInvoice() + public async Task Create_RecordsTheSwap_ThenStartsItsPayment_WithoutWaitingForIt() { InvoiceIssued(); - NodePays(Payment.Types.PaymentStatus.Succeeded, feeSat: 9); var swapOut = Template(); SwapOut? recorded = null; _swapOuts.AddAsync(Arg.Do(s => recorded = new SwapOut @@ -198,9 +200,11 @@ public async Task Create_RecordsTheSwapBeforePayingTheInvoice() NSubstitute.Received.InOrder(() => { _swapOuts.AddAsync(swapOut); - _lightning.SendPaymentV2Async(_node, Invoice, 0, 5_000_000, null, null, 600, CancellationToken.None); - _swapOuts.Update(swapOut); + _payments.Launch(Arg.Is(p => p.Node == _node && p.SwapId == swapOut.Id && p.PaymentRequest == Invoice && + p.FeeLimitMsat == 5_000_000 && p.ChannelsOut == null && + p.TimeoutSeconds == 600)); }); + await _lightning.DidNotReceiveWithAnyArgs().SendPaymentV2Async(default!, default!, default, default, default, default, default, default); swapOut.ProviderId.Should().Be("req-1"); swapOut.PaymentHash.Should().Be(Hash); swapOut.DestinationAddress.Should().BeNull("a Spark swap reserves its address when it exits"); @@ -208,63 +212,87 @@ public async Task Create_RecordsTheSwapBeforePayingTheInvoice() swapOut.SparkIdentity.Should().Be(Identity); swapOut.SatsAmount.Should().Be(500_000); swapOut.Status.Should().Be(SwapOutStatus.Pending); - swapOut.LightningFeeSats.Should().Be(9); + swapOut.LightningFeeSats.Should().BeNull("the payment's outcome is saved once it is known"); response.Status.Should().Be(SwapOutStatus.Pending); - response.OffchainFee.Should().Be(9); } [Fact] - public async Task Create_PaysEvenIfTheCallerCancels() + public async Task Create_PaysWithTheNodesOwnPaymentTimeout() { InvoiceIssued(); - NodePays(Payment.Types.PaymentStatus.Succeeded); - using var cancellation = new CancellationTokenSource(); + _node.SparkPaymentTimeoutMinutes = 3; - await Service().CreateSwapOutAsync(_node, Template(), Request(), cancellation.Token); + await Service().CreateSwapOutAsync(_node, Template(), Request()); - await _lightning.Received(1).SendPaymentV2Async(_node, Invoice, Arg.Is(0L), Arg.Any(), Arg.Any(), null, - Arg.Any(), CancellationToken.None); + _payments.Received(1).Launch(Arg.Is(p => p.TimeoutSeconds == 180)); } [Fact] - public async Task Create_WhenThePaymentFails_FailsTheSwap() + public async Task Create_WhenTheSwapCannotBeRecorded_DoesNotPay() { InvoiceIssued(); + _swapOuts.AddAsync(Arg.Any()).Returns((false, "the database is unavailable")); + + var (_, saved, error) = await Service().CreateSwapOutAsync(_node, Template(), Request()); + + saved.Should().BeFalse(); + error.Should().Contain("unavailable"); + _payments.DidNotReceiveWithAnyArgs().Launch(default!); + } + + // ── Paying ─────────────────────────────────────────────────────────────────────────── + + [Fact] + public async Task Pay_ASucceededPayment_SavesItsRoutingFee_AndIsNotCancellable() + { + var swap = Pending(lightningFee: null); + _swapOuts.GetById(7).Returns(swap); + NodePays(Payment.Types.PaymentStatus.Succeeded, feeSat: 9); + + await Service().PayAsync(ThePayment()); + + await _lightning.Received(1).SendPaymentV2Async(_node, Invoice, 0, 5_000_000, null, null, 600, CancellationToken.None); + swap.LightningFeeSats.Should().Be(9); + swap.Status.Should().Be(SwapOutStatus.Pending, "the exit job takes it from here"); + _swapOuts.Received(1).Update(swap); + } + + [Fact] + public async Task Pay_WhenThePaymentFails_FailsTheSwap() + { + var swap = Pending(lightningFee: null); + _swapOuts.GetById(7).Returns(swap); NodePays(Payment.Types.PaymentStatus.Failed, PaymentFailureReason.FailureReasonNoRoute); - var swapOut = Template(); - var (response, _, _) = await Service().CreateSwapOutAsync(_node, swapOut, Request()); + await Service().PayAsync(ThePayment()); - response.Status.Should().Be(SwapOutStatus.Failed); - swapOut.Status.Should().Be(SwapOutStatus.Failed); - swapOut.ErrorDetails.Should().Contain("FailureReasonNoRoute"); - _swapOuts.Received(1).Update(swapOut); + swap.Status.Should().Be(SwapOutStatus.Failed); + swap.ErrorDetails.Should().Contain("FailureReasonNoRoute"); + _swapOuts.Received(1).Update(swap); } [Fact] - public async Task Create_WhenThePaymentOutcomeIsUnknown_LeavesItToTheMonitor() + public async Task Pay_WhenThePaymentOutcomeIsUnknown_LeavesItToTheMonitor() { - InvoiceIssued(); + var swap = Pending(lightningFee: null); + _swapOuts.GetById(7).Returns(swap); // SendPaymentV2 reports a broken stream as FailureReasonError: the payment may still succeed NodePays(Payment.Types.PaymentStatus.Failed, PaymentFailureReason.FailureReasonError); - var swapOut = Template(); - var (response, _, _) = await Service().CreateSwapOutAsync(_node, swapOut, Request()); + await Service().PayAsync(ThePayment()); - response.Status.Should().Be(SwapOutStatus.Pending); - swapOut.LightningFeeSats.Should().BeNull(); + swap.Status.Should().Be(SwapOutStatus.Pending); + swap.LightningFeeSats.Should().BeNull(); + _swapOuts.DidNotReceiveWithAnyArgs().Update(default!); } [Fact] - public async Task Create_WhenTheSwapCannotBeRecorded_DoesNotPay() + public async Task Pay_ASwapNoLongerWaitingForItsPayment_IsNotPaid() { - InvoiceIssued(); - _swapOuts.AddAsync(Arg.Any()).Returns((false, "the database is unavailable")); + _swapOuts.GetById(7).Returns(Pending(lightningFee: 3)); - var (_, saved, error) = await Service().CreateSwapOutAsync(_node, Template(), Request()); + await Service().PayAsync(ThePayment()); - saved.Should().BeFalse(); - error.Should().Contain("unavailable"); await _lightning.DidNotReceiveWithAnyArgs().SendPaymentV2Async(default!, default!, default, default, default, default, default, default); } From c80278bd39ac4ceed409742c33f8806d3f162e75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:55:25 +0200 Subject: [PATCH 3/6] Spark wallet details: a Transactions tab with each transfer's date, type and swap The details of a Spark wallet get a Transactions tab: the wallet's latest transfers, newest first, with their date, direction, type (a Lightning payment in, an exit to on-chain, a Spark transfer), amount, status and the swap each belongs to (the one it paid in, or whose leaves it exited). --- .../Interfaces/ISwapOutRepository.cs | 3 + src/Data/Repositories/SwapOutRepository.cs | 11 ++ src/Services/Spark/SparkWalletService.cs | 59 ++++++++- src/Shared/SparkWalletModals.razor | 117 +++++++++++++++++- .../Spark/SparkWalletManagementTests.cs | 55 ++++++++ 5 files changed, 243 insertions(+), 2 deletions(-) diff --git a/src/Data/Repositories/Interfaces/ISwapOutRepository.cs b/src/Data/Repositories/Interfaces/ISwapOutRepository.cs index 5c5f69c3..202f78ff 100644 --- a/src/Data/Repositories/Interfaces/ISwapOutRepository.cs +++ b/src/Data/Repositories/Interfaces/ISwapOutRepository.cs @@ -57,4 +57,7 @@ public interface ISwapOutRepository /// The Spark transfers already given to a swap, whatever its status: a transfer belongs to one swap only ///
    Task> GetSparkTransferIdsAsync(); + + /// The Spark swaps paid into the Spark wallet with this identity, whatever their status + Task> GetSparkSwapsByIdentityAsync(string identityPublicKey); } \ No newline at end of file diff --git a/src/Data/Repositories/SwapOutRepository.cs b/src/Data/Repositories/SwapOutRepository.cs index 53700c68..c102ad10 100644 --- a/src/Data/Repositories/SwapOutRepository.cs +++ b/src/Data/Repositories/SwapOutRepository.cs @@ -214,5 +214,16 @@ public async Task> GetSparkTransferIdsAsync() return transferIds.ToHashSet(StringComparer.OrdinalIgnoreCase); } + + public async Task> GetSparkSwapsByIdentityAsync(string identityPublicKey) + { + await using var context = await _dbContextFactory.CreateDbContextAsync(); + var identity = identityPublicKey.ToLowerInvariant(); + + return await context.SwapOuts + .AsNoTracking() + .Where(s => s.Provider == SwapProvider.Spark && s.SparkIdentity != null && s.SparkIdentity.ToLower() == identity) + .ToListAsync(); + } } } \ No newline at end of file diff --git a/src/Services/Spark/SparkWalletService.cs b/src/Services/Spark/SparkWalletService.cs index 4b08cc4e..9c7e05d5 100644 --- a/src/Services/Spark/SparkWalletService.cs +++ b/src/Services/Spark/SparkWalletService.cs @@ -66,6 +66,13 @@ public sealed record SparkWalletSummary(int Id, string Name, string? IdentityPub /// A new Spark wallet and its mnemonic, which is shown once and never again. 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. +public sealed record SparkWalletTransfer(string Id, DateTimeOffset Date, bool IsSent, string Type, long AmountSats, string Status, + int? SwapId); + /// /// NodeGuard's Spark wallets: the Spark wallet rows (SPARK_SIGNER=wallet) or the remote signer's single wallet /// (SPARK_SIGNER=remote). Their keys stay with their signer (see ). Each wallet @@ -155,6 +162,10 @@ Task WithdrawLeavesAsync(SparkWalletRef wallet, IReadOnlyC /// Archives the wallet; refused unless it is empty and no node uses it. Task ArchiveWalletAsync(int walletId, CancellationToken ct = default); + + /// The wallet's latest transfers, newest first, each with the swap it belongs to. + /// The wallet can't be reached. + Task> ListTransfersAsync(int walletId, int limit = 100, CancellationToken ct = default); } /// @@ -287,7 +298,7 @@ public virtual Task ClaimPendingAsync(SparkWalletRef walle public Task GetTransferAsync(SparkWalletRef wallet, string transferId, CancellationToken ct = default) => WithWalletAsync(wallet, w => w.GetTransferAsync(transferId, ct), ct); - public Task> GetTransfersAsync(SparkWalletRef wallet, TransferDirection direction, + public virtual Task> GetTransfersAsync(SparkWalletRef wallet, TransferDirection direction, DateTimeOffset since, CancellationToken ct = default) => WithWalletAsync(wallet, async w => { @@ -495,6 +506,52 @@ await scope.ServiceProvider.GetRequiredService().LogAsync(AuditAc Forget(new SparkWalletRef(walletId)); } + public async Task> ListTransfersAsync(int walletId, int limit = 100, CancellationToken ct = default) + { + using var scope = _scopes.CreateScope(); + var wallet = await SparkWalletRowAsync(scope.ServiceProvider.GetRequiredService(), walletId); + var swaps = wallet.SparkIdentityPublicKey is { } identity + ? await scope.ServiceProvider.GetRequiredService().GetSparkSwapsByIdentityAsync(identity) + : []; + + var transfers = await GetTransfersAsync(new SparkWalletRef(walletId), TransferDirection.Both, DateTimeOffset.UnixEpoch, ct); + return transfers + .OrderByDescending(t => t.CreatedAt) + .Take(limit) + .Select(t => + { + 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); + }) + .ToList(); + } + + private static string TransferTypeLabel(string? type) => type switch + { + "PreimageSwap" => "Lightning payment in", + "CooperativeExit" => "Exit to on-chain", + "Transfer" => "Spark transfer", + null or "" => "Unknown", + _ => type + }; + + /// 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) + { + if (!sent) + { + return swaps.FirstOrDefault(s => string.Equals(s.SparkTransferId, transfer.Id, StringComparison.OrdinalIgnoreCase)); + } + + var leaves = transfer.Leaves.Select(l => l.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); + return leaves.Count == 0 + ? null + : swaps.FirstOrDefault(s => (s.SparkLeafIds ?? string.Empty) + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Any(leaves.Contains)); + } + private static async Task SparkWalletRowAsync(IWalletRepository wallets, int walletId) => await wallets.GetById(walletId) is { Kind: WalletKind.Spark } wallet ? wallet diff --git a/src/Shared/SparkWalletModals.razor b/src/Shared/SparkWalletModals.razor index 2bb1f51e..55d7ef16 100644 --- a/src/Shared/SparkWalletModals.razor +++ b/src/Shared/SparkWalletModals.razor @@ -1,3 +1,4 @@ +@using Humanizer @using NodeGuard.Services.Spark @using NSpark.Models @inject ISparkWalletService SparkWallet @@ -81,6 +82,13 @@ @if (_wallet is not null) { var status = SparkWallet.GetStatus(new SparkWalletRef(_wallet.Id)); + + + Details + Transactions + + + Identity@_wallet.SparkIdentityPublicKey @@ -118,10 +126,76 @@ }
    +
    + + @if (_loadingTransfers) + { + Reading the wallet's transfers from the Spark operators… + } + else if (_transfersError is not null) + { + Not available: @_transfersError + } + else if (_transfers is { Count: 0 }) + { + No transfers yet. + } + else if (_transfers is not null) + { + + + + Date (UTC) + Direction + Type + Amount + Status + Swap + Transfer + + + + @foreach (var transfer in _transfers) + { + + + + @transfer.Date.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss") + + + @(transfer.IsSent ? "Sent" : "Received") + @transfer.Type + + + @($"{new NBitcoin.Money(transfer.AmountSats, NBitcoin.MoneyUnit.Satoshi).ToUnit(NBitcoin.MoneyUnit.BTC):f8} BTC") + + + @transfer.Status + @(transfer.SwapId is { } swapId ? $"#{swapId}" : "") + + + @(transfer.Id.Length > 8 ? transfer.Id[..8] + "…" : transfer.Id) + + + + } + +
    + } +
    +
    +
    } - + @if (_detailsTab == "Transactions") + { + + } + else + { + + } @@ -209,6 +283,10 @@ private bool _loadingBalance; private string? _balanceError; private List _usedBy = new(); + private string _detailsTab = "Details"; + private IReadOnlyList? _transfers; + private bool _loadingTransfers; + private string? _transfersError; private List _destinations = new(); private int _destinationWalletId; @@ -226,11 +304,48 @@ { _wallet = wallet; _balance = null; + _detailsTab = "Details"; + _transfers = null; + _transfersError = null; _usedBy = (await NodeRepository.GetAll()).Where(n => n.SparkWalletId == wallet.Id).Select(n => n.Name).ToList(); await _detailsModal.Show(); await RefreshBalanceAsync(); } + /// The transfers are read the first time their tab opens + private async Task OnDetailsTabChanged(string name) + { + _detailsTab = name; + if (name == "Transactions" && _transfers is null && !_loadingTransfers) await RefreshTransfersAsync(); + } + + private async Task RefreshTransfersAsync() + { + if (_wallet is null) return; + + _loadingTransfers = true; + _transfersError = null; + StateHasChanged(); + try + { + _transfers = await SparkWallet.ListTransfersAsync(_wallet.Id); + } + catch (Exception e) when (e is InvalidOperationException or ArgumentException or SparkUnavailableException) + { + _transfersError = e.Message; + } + catch (Exception e) + { + Logger.LogError(e, "Could not read the transfers of Spark wallet {WalletId}", _wallet.Id); + _transfersError = "the Spark operators could not be reached"; + } + finally + { + _loadingTransfers = false; + StateHasChanged(); + } + } + public async Task ShowMaxBalanceAsync(Wallet wallet) { _wallet = wallet; diff --git a/test/NodeGuard.Tests/Services/Spark/SparkWalletManagementTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkWalletManagementTests.cs index f1230ca3..a8567f5b 100644 --- a/test/NodeGuard.Tests/Services/Spark/SparkWalletManagementTests.cs +++ b/test/NodeGuard.Tests/Services/Spark/SparkWalletManagementTests.cs @@ -28,6 +28,7 @@ using NodeGuard.Data.Repositories.Interfaces; using NodeGuard.TestHelpers; using NSpark.Models; +using NSpark.Services; using NSpark.Signer; using NSubstitute; using NSubstitute.Extensions; @@ -90,6 +91,7 @@ public SparkWalletManagementTests() _wallets.GetById(5).Returns(_ => SparkWallet()); _nodes.GetAll().Returns(new List()); _swapOuts.GetAllPending().Returns(new List()); + _swapOuts.GetSparkSwapsByIdentityAsync(Arg.Any()).Returns(new List()); Holds(0); _destination = CreateWallet.SingleSig(CreateWallet.CreateInternalWallet()); @@ -282,6 +284,59 @@ public async Task Archive_AWalletWithSats_IsRefused() _wallets.DidNotReceiveWithAnyArgs().Update(default!); } + // ── Transactions ───────────────────────────────────────────────────────────────────── + + private static SparkTransfer Transfer(string id, string sender, string type, int minutes, params string[] leafIds) => + new(id, sender, sender == Identity ? "03ss" : Identity, 100_000, "Completed", DateTimeOffset.UnixEpoch.AddMinutes(minutes), type) + { + Leaves = leafIds.Select(l => new SparkLeaf(l, "tree", 50_000, "AVAILABLE")).ToList() + }; + + [Fact] + public async Task TheTransactions_AreNewestFirst_WithTheirDirectionTypeAndSwap() + { + _spark.Configure().GetTransfersAsync(new SparkWalletRef(5), TransferDirection.Both, DateTimeOffset.UnixEpoch, Arg.Any()) + .Returns(new List + { + Transfer("receive-1", "03ss", "PreimageSwap", minutes: 1, "l1", "l2"), + Transfer("exit-1", Identity, "CooperativeExit", minutes: 5, "l2"), + Transfer("stray", "03ss", "Transfer", minutes: 3, "x1"), + Transfer("odd", "03ss", "UtxoSwap", minutes: 2) + }); + _swapOuts.GetSparkSwapsByIdentityAsync(Identity).Returns(new List + { + new() { Id = 41, Provider = SwapProvider.Spark, SparkTransferId = "receive-1", SparkLeafIds = "l1,l2" } + }); + + var transfers = await Service().ListTransfersAsync(5); + + 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) } + }, o => o.WithStrictOrdering()); + } + + [Fact] + public async Task TheTransactions_KeepOnlyTheLatest() + { + _spark.Configure().GetTransfersAsync(new SparkWalletRef(5), TransferDirection.Both, DateTimeOffset.UnixEpoch, Arg.Any()) + .Returns(Enumerable.Range(1, 5).Select(i => Transfer($"t{i}", "03ss", "Transfer", minutes: i)).ToList()); + + var transfers = await Service().ListTransfersAsync(5, limit: 2); + + transfers.Select(t => t.Id).Should().Equal("t5", "t4"); + } + + [Fact] + public async Task TheTransactions_OfAnOnChainWallet_AreRefused() + { + await Service().Invoking(s => s.ListTransfersAsync(3)).Should().ThrowAsync().WithMessage("*not a Spark wallet*"); + } + [Fact] public async Task AnOnChainWallet_IsNotManagedHere() { From 4b38938a9480ded3eca442b43f83cbc569cb2ccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:59:18 +0200 Subject: [PATCH 4/6] Pin NSpark: exits at a chosen speed Takes Elenpay/nspark 0a70341: every withdrawal takes an ExitSpeed (FAST by default, so nothing changes yet). --- src/Services/Spark/SparkWalletService.cs | 4 ++-- vendor/nspark | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Services/Spark/SparkWalletService.cs b/src/Services/Spark/SparkWalletService.cs index 9c7e05d5..11f3391c 100644 --- a/src/Services/Spark/SparkWalletService.cs +++ b/src/Services/Spark/SparkWalletService.cs @@ -320,11 +320,11 @@ public virtual Task GetBalanceAsync(SparkWalletRef wallet, Cancel public virtual Task WithdrawAllAsync(SparkWalletRef wallet, string onChainAddress, long maxFeeSats, CancellationToken ct = default) => - WithWalletAsync(wallet, w => w.WithdrawAllAsync(onChainAddress, maxFeeSats, ct), ct); + WithWalletAsync(wallet, w => w.WithdrawAllAsync(onChainAddress, maxFeeSats, ct: ct), ct); public Task WithdrawLeavesAsync(SparkWalletRef wallet, IReadOnlyCollection leafIds, string onChainAddress, long maxFeeSats, CancellationToken ct = default) => - WithWalletAsync(wallet, w => w.WithdrawLeavesAsync(leafIds, onChainAddress, maxFeeSats, ct), ct); + WithWalletAsync(wallet, w => w.WithdrawLeavesAsync(leafIds, onChainAddress, maxFeeSats, ct: ct), ct); public virtual void Forget(SparkWalletRef wallet) => _slots.TryRemove(wallet, out _); diff --git a/vendor/nspark b/vendor/nspark index e74a6d16..0a70341e 160000 --- a/vendor/nspark +++ b/vendor/nspark @@ -1 +1 @@ -Subproject commit e74a6d1603747af8529378dc29d450a7c717d2fb +Subproject commit 0a70341e465d6a6e08f7d4b28de7fc8a7b966de1 From 9846163082b4f7fe7eed5a56bade73c627240dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:05:49 +0200 Subject: [PATCH 5/6] Spark swaps exit at SPARK_EXIT_SPEED, SLOW by default, with guardrails that wait for slow exits SPARK_EXIT_SPEED (FAST, MEDIUM or SLOW; SLOW, the cheapest, by default) sets the speed of every swap exit and of Withdraw all. An exit's leaves stay the wallet's until it confirms, so a swap whose exit was sent still accounts for them until it completes, and the overdue alert waits 6, 12 or 24 hours with the speed. The New Swap confirmation and the Withdraw all dialog state the speed. --- docs/spark-swaps.md | 14 ++++--- src/Services/Spark/SparkGuardrails.cs | 15 +++---- src/Services/Spark/SparkSettings.cs | 39 +++++++++++++++++++ src/Services/Spark/SparkWalletService.cs | 12 +++--- src/Shared/NewSwapModal.razor | 3 +- src/Shared/SparkWalletModals.razor | 1 + .../Services/Spark/SparkGuardrailsTests.cs | 33 +++++++++++----- .../Services/Spark/SparkSettingsTests.cs | 22 +++++++++++ 8 files changed, 110 insertions(+), 29 deletions(-) diff --git a/docs/spark-swaps.md b/docs/spark-swaps.md index cf73be8b..428a2f67 100644 --- a/docs/spark-swaps.md +++ b/docs/spark-swaps.md @@ -22,16 +22,17 @@ The Spark client is the [NSpark](https://github.com/orklabs/nspark) .NET SDK, fr **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. 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. + - 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 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. +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. 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. **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, 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 swap's leaves held minus the payout. Each fee shows its BTC amount and its percentage of the swap, with ppm and USD in the tooltip. @@ -40,7 +41,7 @@ Each fee shows its BTC amount and its percentage of the swap, with ppm and USD i Spark swaps are trust-minimized, not trustless the way Loop's are. A Loop swap is an on-chain HTLC: either the node's Lightning payment fails or the destination is paid on-chain, and only the Loop server's liveness is trusted. A Spark swap goes through three steps: 1. **Lightning into Spark is atomic.** The operators hold shares of the invoice's preimage and release it to the SSP only when the SSP transfers the Spark leaves to the Spark wallet's identity. A threshold of operators (two of the three on mainnet: Lightspark, Breez and Flashnet) could release it without that transfer. -2. **While the sats are on Spark**, from the payment until the exit confirms (minutes), they rely on the same operator threshold, which co-signs every leaf transfer. If the operators disappear, a unilateral exit with the leaves' pre-signed refund transactions still recovers the sats, but it is slow (timelocks) and costs on-chain fees. NSpark can snapshot what that exit needs (`RecoveryService.GetRecoverySnapshotAsync`); NodeGuard doesn't automate it. +2. **While the sats are on Spark**, from the payment until the exit confirms (minutes at `FAST`, possibly hours at `SLOW`), they rely on the same operator threshold, which co-signs every leaf transfer. If the operators disappear, a unilateral exit with the leaves' pre-signed refund transactions still recovers the sats, but it is slow (timelocks) and costs on-chain fees. NSpark can snapshot what that exit needs (`RecoveryService.GetRecoverySnapshotAsync`); NodeGuard doesn't automate it. 3. **The exit to on-chain is atomic.** Before signing anything, NSpark checks that the SSP's exit transaction pays the reserved address at least the amount minus the fee cap, and that the connector transaction spends it. The operators release the leaves to the SSP only once that exit transaction confirms. So the exposure is the sats in transit, while they are on Spark. That is why a Spark wallet is transit only: its max balance caps it, and the guardrails alert when sats get stuck or an exit is overdue. @@ -62,7 +63,7 @@ Spark wallets let NodeGuard use Spark without the remote signer, for instance fo - **Storage.** The mnemonic is encrypted with ASP.NET Data Protection (purpose `NodeGuard.SparkWallet.Mnemonic.v1`). The key ring is NodeGuard's own, in Postgres (`DataProtectionKeys`), so it is shared by every replica and survives restarts and redeploys. This protects against a dump of the `Wallets` table alone, not of the whole database: the key ring itself is stored unencrypted. If the key ring is lost or NodeGuard's application name changes, the mnemonics can't be decrypted, and the words shown at creation are the only way back. A wallet whose seed doesn't decrypt is reported unavailable. - **Using one.** Nodes page ▸ liquidity settings ▸ Spark wallet, next to the Spark weight. A Spark weight above 0 needs a Spark wallet. Several nodes may share one. A node's Spark wallet can't change while the node has a Spark swap in flight. Until a node has one, the New Swap dialog lists no node for Spark and says so. - **Max balance.** Each Spark wallet has its own max balance (Edit max balance), the most it may hold counting a new swap and its swaps not paid yet. Empty means `SPARK_MAX_BALANCE_SATS`. For a mainnet test, keep it small (1-2 M sats). -- **Withdraw all** exits everything the wallet holds to a new address of an on-chain wallet, the fee capped by `SPARK_MAX_EXIT_FEE_SATS`. It is refused while the wallet has a swap in flight, so a swap's sats never leave with the rest. +- **Withdraw all** exits everything the wallet holds to a new address of an on-chain wallet, at `SPARK_EXIT_SPEED`, the fee capped by `SPARK_MAX_EXIT_FEE_SATS`. It is refused while the wallet has a swap in flight, so a swap's sats never leave with the rest. - **Archive** is allowed only for an empty wallet that no node uses. - On mainnet, NodeGuard logs a warning at startup while Spark wallets exist. @@ -86,6 +87,7 @@ While Spark is enabled, invalid settings stop NodeGuard at startup. | `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_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 | | `SPARK_OPERATOR_CERTS_DIR` | none; refused on mainnet | Self-signed operator certificates to pin (regtest) | | `SPARK_SIGNER_RIE_URL` | none; refused on mainnet | Reach a locally run signer image through the Lambda emulator | @@ -107,8 +109,8 @@ With the remote signer, `REMOTE_SIGNER_ENDPOINT` must be the Function URL with n ## Guardrails and alerts 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 exits, what it brought in (or its amount, until that is known) is its own and never counts as stuck. -- `SparkExitOverdue`: a swap was paid six hours ago and still has no confirmed payout. +- `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`). 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). diff --git a/src/Services/Spark/SparkGuardrails.cs b/src/Services/Spark/SparkGuardrails.cs index 79018ea1..08694e12 100644 --- a/src/Services/Spark/SparkGuardrails.cs +++ b/src/Services/Spark/SparkGuardrails.cs @@ -51,7 +51,6 @@ public sealed class SparkGuardrails : ISparkGuardrails, IDisposable { public const string MeterName = "NodeGuard.Spark"; public static readonly TimeSpan StuckAlertAfter = TimeSpan.FromHours(1); - public static readonly TimeSpan ExitOverdueAfter = TimeSpan.FromHours(6); private readonly SparkSettings _settings; private readonly ISparkWalletService _spark; @@ -92,11 +91,12 @@ public static long UnpaidSats(IEnumerable pendingSwaps) => PendingSpark(pendingSwaps).Where(s => s.LightningFeeSats is null).Sum(s => s.SatsAmount); /// - /// What the wallet holds for the pending Spark swaps that have not exited yet: what each one brought in, or its - /// amount until it is known. + /// 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 + /// speed can take hours. /// public static long DueSats(IEnumerable pendingSwaps) => - PendingSpark(pendingSwaps).Where(s => s.TxId is null).Sum(s => s.SparkReceivedSats ?? s.SatsAmount); + PendingSpark(pendingSwaps).Sum(s => s.SparkReceivedSats ?? s.SatsAmount); private static IEnumerable PendingSpark(IEnumerable swaps) => swaps.Where(s => s.Provider == SwapProvider.Spark && s.Status == SwapOutStatus.Pending); @@ -140,8 +140,8 @@ public async Task> CheckAsync(IReadOnlyCollection string.Equals(s.SparkIdentity, wallet.IdentityPublicKey, StringComparison.OrdinalIgnoreCase))); var stuck = held - due; @@ -175,7 +175,8 @@ public async Task> CheckAsync(IReadOnlyCollection s.LightningFeeSats is not null)) { var age = now - swap.CreationDatetime; - if (age < ExitOverdueAfter || !_overdueAlerted.Add(swap.Id)) continue; + // 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"); diff --git a/src/Services/Spark/SparkSettings.cs b/src/Services/Spark/SparkSettings.cs index 23a74cf9..f3473c3f 100644 --- a/src/Services/Spark/SparkSettings.cs +++ b/src/Services/Spark/SparkSettings.cs @@ -21,6 +21,7 @@ using NodeGuard.Data.Models; using NodeGuard.Helpers; using NSpark; +using NSpark.Models; namespace NodeGuard.Services.Spark; @@ -123,6 +124,31 @@ public sealed class SparkSettings /// The payment timeout for 's Spark swaps: its own, else . public int PaymentTimeoutFor(Node node) => node.SparkPaymentTimeoutMinutes ?? PaymentTimeoutMinutes; + /// + /// How fast exits confirm on-chain (SPARK_EXIT_SPEED: FAST, MEDIUM or SLOW): swap exits and Withdraw all. SLOW, + /// the default, is the cheapest: the SSP's L1 broadcast fee is set by the speed's fee rate. + /// + public ExitSpeed ExitSpeed { get; init; } = ExitSpeed.Slow; + + /// + /// How long a paid swap may go without a confirmed payout before it is alerted as overdue: longer the slower + /// its exit confirms. + /// + public TimeSpan ExitOverdueAfter => ExitSpeed switch + { + ExitSpeed.Fast => TimeSpan.FromHours(6), + ExitSpeed.Medium => TimeSpan.FromHours(12), + _ => TimeSpan.FromHours(24) + }; + + /// The exit speed and its trade-off, as the UI shows it. + public string ExitSpeedSummary => ExitSpeed switch + { + ExitSpeed.Fast => "FAST (SPARK_EXIT_SPEED): the dearest, confirming soonest", + ExitSpeed.Medium => "MEDIUM (SPARK_EXIT_SPEED): between FAST and SLOW in fee and in time to confirm", + _ => "SLOW (SPARK_EXIT_SPEED): the cheapest, though the payout can take hours to confirm when fees run high" + }; + /// /// Most sats a Spark wallet may hold, counting a new swap (SPARK_MAX_BALANCE_SATS), unless the wallet has its /// own: Spark balances are only meant to pass through. @@ -279,6 +305,18 @@ public static SparkSettings FromEnvironment(Func env, Network n errors.Add($"SPARK_PAYMENT_TIMEOUT_MINUTES must be a whole number of minutes from 1 to {MaxPaymentTimeoutMinutes}"); } + var exitSpeed = ExitSpeed.Slow; + if (env("SPARK_EXIT_SPEED") is { Length: > 0 } exitSpeedValue) + { + switch (exitSpeedValue.Trim().ToUpperInvariant()) + { + case "FAST": exitSpeed = ExitSpeed.Fast; break; + case "MEDIUM": exitSpeed = ExitSpeed.Medium; break; + case "SLOW": exitSpeed = ExitSpeed.Slow; break; + default: errors.Add("SPARK_EXIT_SPEED must be FAST, MEDIUM or SLOW"); break; + } + } + var maxBalance = DefaultMaxBalanceSats; if (env("SPARK_MAX_BALANCE_SATS") is { Length: > 0 } maxBalanceValue && (!long.TryParse(maxBalanceValue, out maxBalance) || maxBalance <= 0)) @@ -324,6 +362,7 @@ public static SparkSettings FromEnvironment(Func env, Network n MaxExitFeeSats = maxExitFee, ExitIntervalMinutes = exitInterval, PaymentTimeoutMinutes = paymentTimeout, + ExitSpeed = exitSpeed, MaxBalanceSats = maxBalance, SignerEndpoint = endpoint, SignerRieUrl = rieUrl diff --git a/src/Services/Spark/SparkWalletService.cs b/src/Services/Spark/SparkWalletService.cs index 11f3391c..ad749342 100644 --- a/src/Services/Spark/SparkWalletService.cs +++ b/src/Services/Spark/SparkWalletService.cs @@ -124,13 +124,15 @@ Task> GetTransfersAsync(SparkWalletRef wallet, Tran Task GetBalanceAsync(SparkWalletRef wallet, CancellationToken ct = default); - /// Exits every spendable sat to in one cooperative exit. + /// + /// Exits every spendable sat to in one cooperative exit, at SPARK_EXIT_SPEED. + /// Task WithdrawAllAsync(SparkWalletRef wallet, string onChainAddress, long maxFeeSats, CancellationToken ct = default); /// - /// Exits exactly to in one cooperative exit, with no - /// leaf swap (so the remote signer can sign it too). The fee comes out of the leaves' total. + /// Exits exactly to in one cooperative exit, at + /// SPARK_EXIT_SPEED, with no leaf swap (so the remote signer can sign it too). The fee comes out of the leaves' total. /// /// A leaf is not owned or not spendable; nothing moved. Task WithdrawLeavesAsync(SparkWalletRef wallet, IReadOnlyCollection leafIds, string onChainAddress, @@ -320,11 +322,11 @@ public virtual Task GetBalanceAsync(SparkWalletRef wallet, Cancel public virtual Task WithdrawAllAsync(SparkWalletRef wallet, string onChainAddress, long maxFeeSats, CancellationToken ct = default) => - WithWalletAsync(wallet, w => w.WithdrawAllAsync(onChainAddress, maxFeeSats, ct: ct), ct); + WithWalletAsync(wallet, w => w.WithdrawAllAsync(onChainAddress, maxFeeSats, _settings.ExitSpeed, ct), ct); public Task WithdrawLeavesAsync(SparkWalletRef wallet, IReadOnlyCollection leafIds, string onChainAddress, long maxFeeSats, CancellationToken ct = default) => - WithWalletAsync(wallet, w => w.WithdrawLeavesAsync(leafIds, onChainAddress, maxFeeSats, ct: ct), ct); + WithWalletAsync(wallet, w => w.WithdrawLeavesAsync(leafIds, onChainAddress, maxFeeSats, _settings.ExitSpeed, ct), ct); public virtual void Forget(SparkWalletRef wallet) => _slots.TryRemove(wallet, out _); diff --git a/src/Shared/NewSwapModal.razor b/src/Shared/NewSwapModal.razor index 48a22d46..3e73e695 100644 --- a/src/Shared/NewSwapModal.razor +++ b/src/Shared/NewSwapModal.razor @@ -536,7 +536,8 @@ $"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 " + - $"on-chain to the destination wallet on its own, within about {SparkSettings.ExitIntervalOrDefault(Constants.IS_DEV_ENVIRONMENT)} minutes. " + + $"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.

    " + "Please confirm that you want to proceed."; diff --git a/src/Shared/SparkWalletModals.razor b/src/Shared/SparkWalletModals.razor index 55d7ef16..2add5805 100644 --- a/src/Shared/SparkWalletModals.razor +++ b/src/Shared/SparkWalletModals.razor @@ -231,6 +231,7 @@

    Everything the Spark wallet holds exits on-chain, in one cooperative exit, to a new address of the wallet below. The SSP's fee is capped at @SparkSettings.MaxExitFeeSats.ToString("N0") sats (SPARK_MAX_EXIT_FEE_SATS). + It exits at speed @SparkSettings.ExitSpeedSummary; the wallet keeps showing the sats as owned until it confirms.

    Destination wallet diff --git a/test/NodeGuard.Tests/Services/Spark/SparkGuardrailsTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkGuardrailsTests.cs index 6013b002..85f81423 100644 --- a/test/NodeGuard.Tests/Services/Spark/SparkGuardrailsTests.cs +++ b/test/NodeGuard.Tests/Services/Spark/SparkGuardrailsTests.cs @@ -32,7 +32,7 @@ public class SparkGuardrailsTests private readonly ISparkWalletService _spark = Substitute.For(); private readonly ManualTime _time = new(); - private readonly SparkGuardrails _guardrails; + private SparkGuardrails _guardrails; public SparkGuardrailsTests() { @@ -41,9 +41,12 @@ public SparkGuardrailsTests() .Returns(new SparkWalletStatus(SparkWalletState.Ready, "02aa")); Holds(A, 0); Holds(B, 0); - _guardrails = new SparkGuardrails(new SparkSettings { Enabled = true }, _spark, NullLogger.Instance, _time); + _guardrails = Guardrails(ExitSpeed.Slow); } + private SparkGuardrails Guardrails(ExitSpeed exitSpeed) => + new(new SparkSettings { Enabled = true, ExitSpeed = exitSpeed }, _spark, NullLogger.Instance, _time); + private void Wallets(params SparkWalletEntry[] wallets) => _spark.GetWalletsAsync(Arg.Any()).Returns(wallets); @@ -120,12 +123,17 @@ public async Task SatsBeyondWhatTheSwapsInFlightBroughtIn_AreStuck() } [Fact] - public async Task AnExitedSwap_NoLongerAccountsForWhatTheWalletHolds() + public async Task AnExitedSwap_StillAccountsForItsLeaves_UntilItCompletes() { - Holds(A, 10_000); + // A sent exit's leaves stay the wallet's until the exit confirms + Holds(A, 499_000); + var swap = PaidSparkSwap(_time.GetUtcNow(), received: 499_000, txId: "exit-tx"); - await _guardrails.CheckAsync([PaidSparkSwap(_time.GetUtcNow(), received: 499_000, txId: "exit-tx")]); + await _guardrails.CheckAsync([swap]); + _guardrails.StuckSince(A).Should().BeNull(); + swap.Status = SwapOutStatus.Completed; + await _guardrails.CheckAsync([swap]); _guardrails.StuckSince(A).Should().NotBeNull(); } @@ -143,7 +151,7 @@ public void UnpaidAndDueSats_CountOnlyPendingSparkSwaps() ]; SparkGuardrails.UnpaidSats(swaps).Should().Be(100_000); - SparkGuardrails.DueSats(swaps).Should().Be(599_000); + SparkGuardrails.DueSats(swaps).Should().Be(899_000); } [Fact] @@ -177,15 +185,20 @@ public async Task AWalletNoLongerListed_IsForgotten() _guardrails.StuckSince(A).Should().BeNull(); } - [Fact] - public async Task APaidSwapWithoutAConfirmedPayout_IsAlertedOnceWhenOverdue() + [Theory] + [InlineData(ExitSpeed.Fast, 6)] + [InlineData(ExitSpeed.Medium, 12)] + [InlineData(ExitSpeed.Slow, 24)] + public async Task APaidSwapWithoutAConfirmedPayout_IsAlertedOnceWhenOverdueForItsExitSpeed(ExitSpeed exitSpeed, int hours) { + _guardrails = Guardrails(exitSpeed); var swap = PaidSparkSwap(_time.GetUtcNow()); Holds(A, 500_000); + _time.Advance(TimeSpan.FromHours(hours) - TimeSpan.FromMinutes(1)); (await _guardrails.CheckAsync([swap])).Should().BeEmpty(); - _time.Advance(SparkGuardrails.ExitOverdueAfter); + _time.Advance(TimeSpan.FromMinutes(1)); var alerts = await _guardrails.CheckAsync([swap]); alerts.Should().ContainSingle().Which.Should().Match(a => a.Action == AuditActionType.SparkExitOverdue && a.ObjectType == AuditObjectType.SwapOut && a.ObjectId == "req-5"); @@ -198,7 +211,7 @@ public async Task AnUnpaidSwap_IsNotAnOverdueExit() { var swap = PaidSparkSwap(_time.GetUtcNow()); swap.LightningFeeSats = null; - _time.Advance(SparkGuardrails.ExitOverdueAfter * 2); + _time.Advance(TimeSpan.FromDays(2)); (await _guardrails.CheckAsync([swap])).Should().BeEmpty(); } diff --git a/test/NodeGuard.Tests/Services/Spark/SparkSettingsTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkSettingsTests.cs index 29d60bc5..cfffbc94 100644 --- a/test/NodeGuard.Tests/Services/Spark/SparkSettingsTests.cs +++ b/test/NodeGuard.Tests/Services/Spark/SparkSettingsTests.cs @@ -21,6 +21,7 @@ using NBitcoin; using NodeGuard.Data.Models; using NSpark; +using NSpark.Models; namespace NodeGuard.Services.Spark; @@ -137,6 +138,8 @@ public void OnMainnet_LightsparksOperatorsAndSspAreTheDefaults() settings.IdentityPublicKey.Should().Be(Identity); settings.MaxExitFeeSats.Should().Be(SparkSettings.DefaultMaxExitFeeSats); settings.ExitIntervalMinutes.Should().BeNull("the exit job then runs every 10 minutes, 1 in a dev environment"); + settings.ExitSpeed.Should().Be(ExitSpeed.Slow, "the cheapest exit"); + settings.ExitOverdueAfter.Should().Be(TimeSpan.FromHours(24)); settings.MaxBalanceSats.Should().Be(SparkSettings.DefaultMaxBalanceSats); settings.SignerEndpoint.Should().Be(new Uri("https://abc.lambda-url.eu-central-1.on.aws/")); @@ -163,6 +166,7 @@ public void OnRegtest_TheConfiguredNetworkIsRead() env["SPARK_MAX_EXIT_FEE_SATS"] = "5000"; env["SPARK_EXIT_INTERVAL_MINUTES"] = "3"; env["SPARK_PAYMENT_TIMEOUT_MINUTES"] = "15"; + env["SPARK_EXIT_SPEED"] = "fast"; env["SPARK_MAX_BALANCE_SATS"] = "2000000"; var settings = Read(env, Network.RegTest); @@ -176,6 +180,8 @@ public void OnRegtest_TheConfiguredNetworkIsRead() settings.MaxExitFeeSats.Should().Be(5000); settings.ExitIntervalMinutes.Should().Be(3); settings.PaymentTimeoutMinutes.Should().Be(15); + settings.ExitSpeed.Should().Be(ExitSpeed.Fast); + settings.ExitOverdueAfter.Should().Be(TimeSpan.FromHours(6)); settings.MaxBalanceSats.Should().Be(2_000_000); settings.ToSparkOptions(SspIdentity).EffectiveSspIdentityPublicKeyHex.Should().Be(SspIdentity); } @@ -198,6 +204,7 @@ public void OnRegtest_TheConfiguredNetworkIsRead() [InlineData("SPARK_PAYMENT_TIMEOUT_MINUTES", "0", "SPARK_PAYMENT_TIMEOUT_MINUTES must be a whole number of minutes from 1 to 25")] [InlineData("SPARK_PAYMENT_TIMEOUT_MINUTES", "26", "SPARK_PAYMENT_TIMEOUT_MINUTES")] [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")] public void InvalidSettings_FailTheStartup(string variable, string? value, string expected) { @@ -207,6 +214,21 @@ public void InvalidSettings_FailTheStartup(string variable, string? value, strin Error(env).Should().Contain(expected); } + [Theory] + [InlineData("MEDIUM", ExitSpeed.Medium, 12)] + [InlineData(" Slow ", ExitSpeed.Slow, 24)] + [InlineData("", ExitSpeed.Slow, 24)] + public void TheExitSpeed_SetsHowLongAnExitMayTakeBeforeItIsOverdue(string value, ExitSpeed expected, int overdueHours) + { + var env = Remote(); + env["SPARK_EXIT_SPEED"] = value; + + var settings = Read(env); + + settings.ExitSpeed.Should().Be(expected); + settings.ExitOverdueAfter.Should().Be(TimeSpan.FromHours(overdueHours)); + } + [Theory] [InlineData(null)] [InlineData("")] From 48423d11a081a1454ad36d48aa11344bc25c8871 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:42:37 +0200 Subject: [PATCH 6/6] Spark swaps: fire the payment from SparkSwapService, without a launcher service Nothing the payment uses is disposed when the request that created the swap ends (the swap repository opens a context per call; SendPaymentV2Async only uses the singleton router), so SparkSwapService fires it itself with Task.Run. The swap is read again once the payment ends, so a swap the monitor resolved meanwhile isn't overwritten; an error is logged and left to the monitor. --- src/Program.cs | 1 - src/Services/Spark/SparkPaymentLauncher.cs | 67 ------------------ src/Services/Spark/SparkSwapService.cs | 53 ++++++++------ .../Spark/SparkPaymentLauncherTests.cs | 70 ------------------- .../Services/Spark/SparkSwapServiceTests.cs | 65 ++++++++++++----- 5 files changed, 80 insertions(+), 176 deletions(-) delete mode 100644 src/Services/Spark/SparkPaymentLauncher.cs delete mode 100644 test/NodeGuard.Tests/Services/Spark/SparkPaymentLauncherTests.cs diff --git a/src/Program.cs b/src/Program.cs index 7557a6f5..1bd8df7f 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -165,7 +165,6 @@ public static async Task Main(string[] args) () => new ImmutableCredentials(Constants.AWS_ACCESS_KEY_ID, Constants.AWS_SECRET_ACCESS_KEY, null), Constants.AWS_REGION)); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); builder.Services.AddTransient(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/src/Services/Spark/SparkPaymentLauncher.cs b/src/Services/Spark/SparkPaymentLauncher.cs deleted file mode 100644 index 898a1334..00000000 --- a/src/Services/Spark/SparkPaymentLauncher.cs +++ /dev/null @@ -1,67 +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 NodeGuard.Data.Models; - -namespace NodeGuard.Services.Spark; - -/// A recorded Spark swap's Lightning payment, as its creation set it up. -/// How long LND keeps trying routes (the node's Spark payment timeout). -public sealed record SparkPayment(Node Node, int SwapId, string PaymentRequest, long FeeLimitMsat, ulong[]? ChannelsOut, - int TimeoutSeconds); - -/// -/// Pays recorded Spark swaps in the background, so creating one doesn't wait for its payment: the swap shows as -/// pending until the payment's outcome is saved, here or by MonitorSwapsJob. -/// -public interface ISparkPaymentLauncher -{ - void Launch(SparkPayment payment); -} - -public sealed class SparkPaymentLauncher : ISparkPaymentLauncher -{ - private readonly IServiceScopeFactory _scopes; - private readonly ILogger _logger; - - public SparkPaymentLauncher(IServiceScopeFactory scopes, ILogger logger) - { - _scopes = scopes; - _logger = logger; - } - - public void Launch(SparkPayment payment) => _ = Task.Run(() => PayAsync(payment)); - - /// - /// Pays in a scope of its own, as the request that created the swap may be over. A failure here is only - /// logged: MonitorSwapsJob finds the payment by its hash, and fails the swap once its invoice has expired. - /// - internal async Task PayAsync(SparkPayment payment) - { - try - { - using var scope = _scopes.CreateScope(); - await scope.ServiceProvider.GetRequiredService().PayAsync(payment); - } - catch (Exception e) - { - _logger.LogError(e, "Spark swap {SwapId}: its payment could not be followed; MonitorSwapsJob takes over", payment.SwapId); - } - } -} diff --git a/src/Services/Spark/SparkSwapService.cs b/src/Services/Spark/SparkSwapService.cs index 65c0b324..f08d8d50 100644 --- a/src/Services/Spark/SparkSwapService.cs +++ b/src/Services/Spark/SparkSwapService.cs @@ -64,8 +64,8 @@ public enum SparkExitStep public interface ISparkSwapService { /// - /// Creates and records a Spark swap, then starts its payment without waiting for it (see - /// ): the swap is returned pending. See . + /// Creates and records a Spark swap, then starts its payment in the background without waiting for it: the swap + /// is returned pending. See . /// /// Spark is disabled or unavailable. /// @@ -73,13 +73,6 @@ public interface ISparkSwapService /// Task CreateSwapOutAsync(Node node, SwapOut swapOut, SwapOutRequest request, CancellationToken ct = default); - /// - /// Pays a recorded swap's invoice and saves the outcome: the routing fee once paid, the failure once it - /// definitely failed. An unknown outcome is left to MonitorSwapsJob. Not cancellable: once the payment starts - /// it is followed to its end. - /// - Task PayAsync(SparkPayment payment); - /// /// 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. @@ -134,12 +127,11 @@ public sealed class SparkSwapService : ISparkSwapService private readonly ISwapOutRepository _swapOutRepository; private readonly IWalletRepository _walletRepository; private readonly INBXplorerService _nbXplorerService; - private readonly ISparkPaymentLauncher _payments; private readonly TimeProvider _time; public SparkSwapService(ILogger logger, SparkSettings settings, ISparkWalletService spark, ILightningService lightningService, ISwapOutRepository swapOutRepository, IWalletRepository walletRepository, - INBXplorerService nbXplorerService, ISparkPaymentLauncher payments, TimeProvider time) + INBXplorerService nbXplorerService, TimeProvider time) { _logger = logger; _settings = settings; @@ -148,7 +140,6 @@ public SparkSwapService(ILogger logger, SparkSettings settings _swapOutRepository = swapOutRepository; _walletRepository = walletRepository; _nbXplorerService = nbXplorerService; - _payments = payments; _time = time; } @@ -237,28 +228,46 @@ 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 + // 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); - _payments.Launch(new SparkPayment(node, swapOut.Id, paymentRequest, feeLimitMsat, request.ChannelsOut, + _ = Task.Run(() => PayAsync(node, swapOut.Id, paymentRequest, feeLimitMsat, request.ChannelsOut, _settings.PaymentTimeoutFor(node) * 60)); _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); } - public async Task PayAsync(SparkPayment payment) + /// + /// 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. + /// + internal async Task PayAsync(Node node, int swapId, string paymentRequest, long feeLimitMsat, ulong[]? channelsOut, + int timeoutSeconds) { - if (await _swapOutRepository.GetById(payment.SwapId) is not { Status: SwapOutStatus.Pending, LightningFeeSats: null } swap) + try { - _logger.LogWarning("Spark swap {SwapId}: no longer waits for its payment, which is not sent", payment.SwapId); - return; + var result = await _lightningService.SendPaymentV2Async(node, paymentRequest, 0, feeLimitMsat, channelsOut, null, + timeoutSeconds, CancellationToken.None); + await SavePaymentAsync(node, swapId, result); } + catch (Exception e) + { + _logger.LogError(e, "Spark swap {SwapId}: its payment could not be followed; MonitorSwapsJob takes over", swapId); + } + } - // Not cancellable: once the payment starts it must be followed to its end (or left to the monitor) - var node = payment.Node; - var result = await _lightningService.SendPaymentV2Async(node, payment.PaymentRequest, 0, payment.FeeLimitMsat, - payment.ChannelsOut, null, payment.TimeoutSeconds, CancellationToken.None); + 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) + { + _logger.LogInformation("Spark swap {SwapId}: its payment ended ({Status}) after the swap was already resolved", + swapId, result.Status); + return; + } if (result.Status == Payment.Types.PaymentStatus.Succeeded) { diff --git a/test/NodeGuard.Tests/Services/Spark/SparkPaymentLauncherTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkPaymentLauncherTests.cs deleted file mode 100644 index 30ea6515..00000000 --- a/test/NodeGuard.Tests/Services/Spark/SparkPaymentLauncherTests.cs +++ /dev/null @@ -1,70 +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 FluentAssertions; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; -using NodeGuard.Data.Models; -using NSubstitute; -using NSubstitute.ExceptionExtensions; - -namespace NodeGuard.Services.Spark; - -public class SparkPaymentLauncherTests -{ - private readonly ISparkSwapService _swaps = Substitute.For(); - private readonly SparkPayment _payment = new(new Node { Id = 1, Name = "alice" }, 7, "lnbcrt5m1invoice", 5_000_000, null, 600); - - private SparkPaymentLauncher Launcher() => new( - new ServiceCollection().AddScoped(_ => _swaps).BuildServiceProvider().GetRequiredService(), - NullLogger.Instance); - - [Fact] - public async Task APayment_IsMadeByTheSwapServiceOfAScopeOfItsOwn() - { - await Launcher().PayAsync(_payment); - - await _swaps.Received(1).PayAsync(_payment); - } - - [Fact] - public async Task APaymentThatCouldNotBeFollowed_IsLeftToTheMonitor_WithoutThrowing() - { - _swaps.PayAsync(_payment).ThrowsAsync(new InvalidOperationException("LND is down")); - - var act = () => Launcher().PayAsync(_payment); - - await act.Should().NotThrowAsync(); - } - - [Fact] - public async Task Launch_PaysInTheBackground() - { - var paid = new TaskCompletionSource(); - _swaps.PayAsync(_payment).Returns(_ => - { - paid.SetResult(); - return Task.CompletedTask; - }); - - Launcher().Launch(_payment); - - await paid.Task.WaitAsync(TimeSpan.FromSeconds(5)); - } -} diff --git a/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs b/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs index a909f003..a207b34f 100644 --- a/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs +++ b/test/NodeGuard.Tests/Services/Spark/SparkSwapServiceTests.cs @@ -54,7 +54,6 @@ public class SparkSwapServiceTests private readonly ISwapOutRepository _swapOuts = Substitute.For(); private readonly IWalletRepository _wallets = Substitute.For(); private readonly INBXplorerService _nbXplorer = Substitute.For(); - private readonly ISparkPaymentLauncher _payments = 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; @@ -83,9 +82,27 @@ public SparkSwapServiceTests() } private SparkSwapService Service() => new(NullLogger.Instance, Settings, _spark, _lightning, _swapOuts, - _wallets, _nbXplorer, _payments, _time); + _wallets, _nbXplorer, _time); - private SparkPayment ThePayment(int swapId = 7) => new(_node, swapId, Invoice, 5_000_000, null, 600); + private Task Pay(int swapId = 7) => Service().PayAsync(_node, swapId, Invoice, 5_000_000, null, 600); + + /// + /// 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. + /// + private Task PaymentStarted() + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var never = new TaskCompletionSource(); + _lightning.SendPaymentV2Async(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(call => + { + started.TrySetResult(call.ArgAt(6)); + return never.Task; + }); + return started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } // No address: a Spark swap reserves its own when it exits private static SwapOutRequest Request() => new() { Amount = 500_000, MaxRoutingFeesPercent = 1m }; @@ -193,18 +210,18 @@ public async Task Create_RecordsTheSwap_ThenStartsItsPayment_WithoutWaitingForIt ProviderId = s.ProviderId, PaymentHash = s.PaymentHash, Status = s.Status, LightningFeeSats = s.LightningFeeSats })).Returns((true, null)); + var started = PaymentStarted(); + var (response, saved, _) = await Service().CreateSwapOutAsync(_node, swapOut, Request()); saved.Should().BeTrue(); recorded.Should().BeEquivalentTo(new { ProviderId = "req-1", PaymentHash = Hash, Status = SwapOutStatus.Pending, LightningFeeSats = (long?)null }); + (await started).Should().Be(600, "the default payment timeout, 10 minutes"); NSubstitute.Received.InOrder(() => { _swapOuts.AddAsync(swapOut); - _payments.Launch(Arg.Is(p => p.Node == _node && p.SwapId == swapOut.Id && p.PaymentRequest == Invoice && - p.FeeLimitMsat == 5_000_000 && p.ChannelsOut == null && - p.TimeoutSeconds == 600)); + _lightning.SendPaymentV2Async(_node, Invoice, 0, 5_000_000, null, null, 600, CancellationToken.None); }); - await _lightning.DidNotReceiveWithAnyArgs().SendPaymentV2Async(default!, default!, default, default, default, default, default, default); swapOut.ProviderId.Should().Be("req-1"); swapOut.PaymentHash.Should().Be(Hash); swapOut.DestinationAddress.Should().BeNull("a Spark swap reserves its address when it exits"); @@ -221,10 +238,11 @@ public async Task Create_PaysWithTheNodesOwnPaymentTimeout() { InvoiceIssued(); _node.SparkPaymentTimeoutMinutes = 3; + var started = PaymentStarted(); await Service().CreateSwapOutAsync(_node, Template(), Request()); - _payments.Received(1).Launch(Arg.Is(p => p.TimeoutSeconds == 180)); + (await started).Should().Be(180); } [Fact] @@ -237,7 +255,7 @@ public async Task Create_WhenTheSwapCannotBeRecorded_DoesNotPay() saved.Should().BeFalse(); error.Should().Contain("unavailable"); - _payments.DidNotReceiveWithAnyArgs().Launch(default!); + await _lightning.DidNotReceiveWithAnyArgs().SendPaymentV2Async(default!, default!, default, default, default, default, default, default); } // ── Paying ─────────────────────────────────────────────────────────────────────────── @@ -249,7 +267,7 @@ public async Task Pay_ASucceededPayment_SavesItsRoutingFee_AndIsNotCancellable() _swapOuts.GetById(7).Returns(swap); NodePays(Payment.Types.PaymentStatus.Succeeded, feeSat: 9); - await Service().PayAsync(ThePayment()); + await Pay(); await _lightning.Received(1).SendPaymentV2Async(_node, Invoice, 0, 5_000_000, null, null, 600, CancellationToken.None); swap.LightningFeeSats.Should().Be(9); @@ -264,7 +282,7 @@ public async Task Pay_WhenThePaymentFails_FailsTheSwap() _swapOuts.GetById(7).Returns(swap); NodePays(Payment.Types.PaymentStatus.Failed, PaymentFailureReason.FailureReasonNoRoute); - await Service().PayAsync(ThePayment()); + await Pay(); swap.Status.Should().Be(SwapOutStatus.Failed); swap.ErrorDetails.Should().Contain("FailureReasonNoRoute"); @@ -279,7 +297,7 @@ public async Task Pay_WhenThePaymentOutcomeIsUnknown_LeavesItToTheMonitor() // SendPaymentV2 reports a broken stream as FailureReasonError: the payment may still succeed NodePays(Payment.Types.PaymentStatus.Failed, PaymentFailureReason.FailureReasonError); - await Service().PayAsync(ThePayment()); + await Pay(); swap.Status.Should().Be(SwapOutStatus.Pending); swap.LightningFeeSats.Should().BeNull(); @@ -287,13 +305,28 @@ public async Task Pay_WhenThePaymentOutcomeIsUnknown_LeavesItToTheMonitor() } [Fact] - public async Task Pay_ASwapNoLongerWaitingForItsPayment_IsNotPaid() + public async Task Pay_ASwapTheMonitorResolvedMeanwhile_IsNotOverwritten() { - _swapOuts.GetById(7).Returns(Pending(lightningFee: 3)); + var swap = Pending(lightningFee: 3); + _swapOuts.GetById(7).Returns(swap); + NodePays(Payment.Types.PaymentStatus.Succeeded, feeSat: 9); - await Service().PayAsync(ThePayment()); + await Pay(); - await _lightning.DidNotReceiveWithAnyArgs().SendPaymentV2Async(default!, default!, default, default, default, default, default, default); + swap.LightningFeeSats.Should().Be(3); + _swapOuts.DidNotReceiveWithAnyArgs().Update(default!); + } + + [Fact] + public async Task Pay_WhenPayingThrows_LeavesItToTheMonitor() + { + _lightning.SendPaymentV2Async(_node, Invoice, 0, 5_000_000, null, null, 600, CancellationToken.None) + .ThrowsAsync(new InvalidOperationException("LND is down")); + + var act = () => Pay(); + + await act.Should().NotThrowAsync("nothing awaits a background payment"); + _swapOuts.DidNotReceiveWithAnyArgs().Update(default!); } [Theory]