From bb2be202925074c13f5ab1f6a4bec606fe5d70d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:46:00 +0200 Subject: [PATCH] Drop probing in favour of amountless + backoff rebalancing retries Replaces BoS-style binary-search probing with a single amountless self-invoice that is retried at a backoff-shrunk amount. Removes ProbeRouteAsync/ProbeResult from LightningService, the RebalanceStatus.Probing state (value 1 reserved), and renames ProbeBackoffRatio -> AmountBackoffRatio across the model, proto, Constants, gRPC service and UI. Includes the matching unit-test rewrite and the gated e2e rebalance tests. Co-Authored-By: Claude Opus 4.8 (1M context) stack-info: PR: https://github.com/Elenpay/NodeGuard/pull/527, branch: Jossec101/stack/13 --- src/Data/Models/Rebalance.cs | 30 +- .../Interfaces/IRebalanceRepository.cs | 2 +- src/Data/Repositories/RebalanceRepository.cs | 1 - src/Helpers/Constants.cs | 33 +- src/Jobs/MonitorRebalancesJob.cs | 14 +- ...ckoffRatioToAmountBackoffRatio.Designer.cs | 1739 +++++++++++++++++ ...meProbeBackoffRatioToAmountBackoffRatio.cs | 28 + .../ApplicationDbContextModelSnapshot.cs | 6 +- src/NodeGuard.csproj | 2 +- src/Pages/Rebalances.razor | 46 +- src/Proto/nodeguard.proto | 15 +- src/Rpc/NodeGuardService.cs | 6 +- src/Services/LightningService.cs | 115 +- src/Services/RebalanceService.cs | 142 +- src/Shared/NewRebalanceModal.razor | 51 +- test/NodeGuard.Tests/E2E/E2EFactAttribute.cs | 56 + test/NodeGuard.Tests/E2E/RebalanceE2ETests.cs | 175 ++ .../Jobs/MonitorRebalancesJobTests.cs | 70 +- test/NodeGuard.Tests/NodeGuard.Tests.csproj | 1 + .../Rpc/NodeGuardServiceTests.cs | 8 +- .../LightningServiceRebalanceTests.cs | 132 -- .../Services/RebalanceServiceTests.cs | 285 ++- 22 files changed, 2405 insertions(+), 552 deletions(-) create mode 100644 src/Migrations/20260630104437_RenameProbeBackoffRatioToAmountBackoffRatio.Designer.cs create mode 100644 src/Migrations/20260630104437_RenameProbeBackoffRatioToAmountBackoffRatio.cs create mode 100644 test/NodeGuard.Tests/E2E/E2EFactAttribute.cs create mode 100644 test/NodeGuard.Tests/E2E/RebalanceE2ETests.cs delete mode 100644 test/NodeGuard.Tests/Services/LightningServiceRebalanceTests.cs diff --git a/src/Data/Models/Rebalance.cs b/src/Data/Models/Rebalance.cs index 381b0e09..f5d14db7 100644 --- a/src/Data/Models/Rebalance.cs +++ b/src/Data/Models/Rebalance.cs @@ -24,15 +24,16 @@ namespace NodeGuard.Data.Models; public enum RebalanceStatus { - Pending, - Probing, - InFlight, - Succeeded, - Failed, - NoRoute, - Timeout, - InsufficientBalance, - ExceededFeeLimit + Pending = 0, + // 1 was Probing — removed (rebalances go straight to SendPaymentV2, no probe). The value is + // intentionally left as a gap so the other statuses keep their stored int values. + InFlight = 2, + Succeeded = 3, + Failed = 4, + NoRoute = 5, + Timeout = 6, + InsufficientBalance = 7, + ExceededFeeLimit = 8 } public class Rebalance : Entity @@ -132,12 +133,13 @@ public class Rebalance : Entity public int TimeoutSeconds { get; set; } = 60; /// - /// Multiplier applied to the probe amount after each failure. Range: (0, 1) exclusive. - /// 0.5 = halve (default); 0.8 = next try is 20% smaller (finer-grained, more iterations); - /// 0.3 = next try is 70% smaller (gives up faster). When null, the runtime falls back - /// to Constants.REBALANCE_PROBE_BACKOFF_RATIO. + /// Multiplier applied to the rebalanced amount on each retry attempt. Range: (0, 1]. + /// 1 = never shrink (every attempt retries the full requested amount); 0.8 = each retry is + /// 20% smaller; 0.5 = halve each time. The amount for attempt n is + /// RequestedAmountSats × ratio^(n-1), floored at Constants.REBALANCE_MIN_AMOUNT_SATS. + /// When null, the runtime falls back to Constants.REBALANCE_AMOUNT_BACKOFF_RATIO. /// - public double? ProbeBackoffRatio { get; set; } + public double? AmountBackoffRatio { get; set; } /// /// Maximum number of attempts (including the first try). When null, falls back to diff --git a/src/Data/Repositories/Interfaces/IRebalanceRepository.cs b/src/Data/Repositories/Interfaces/IRebalanceRepository.cs index d1bddaa9..9fe1babf 100644 --- a/src/Data/Repositories/Interfaces/IRebalanceRepository.cs +++ b/src/Data/Repositories/Interfaces/IRebalanceRepository.cs @@ -39,7 +39,7 @@ public interface IRebalanceRepository /// /// Returns rebalances the monitor job should reconcile against LND: - /// non-terminal rows (Pending/Probing/InFlight) plus recently-marked terminal failures + /// non-terminal rows (Pending/InFlight) plus recently-marked terminal failures /// within . Only rows with a stored payment hash /// are returned — without a hash there is nothing to look up in LND. /// The recent-terminal sweep exists because the catch-all in RebalanceService can mark diff --git a/src/Data/Repositories/RebalanceRepository.cs b/src/Data/Repositories/RebalanceRepository.cs index 50fca33a..1c59997e 100644 --- a/src/Data/Repositories/RebalanceRepository.cs +++ b/src/Data/Repositories/RebalanceRepository.cs @@ -111,7 +111,6 @@ public async Task> GetReconcilable(TimeSpan recentTerminalWindow .Include(r => r.Node) .Where(r => r.PaymentHashHex != null && (r.Status == RebalanceStatus.Pending - || r.Status == RebalanceStatus.Probing || r.Status == RebalanceStatus.InFlight || ((r.Status == RebalanceStatus.Failed || r.Status == RebalanceStatus.Timeout diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index 84b67ca7..4478b60f 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -179,22 +179,25 @@ public class Constants public static double REBALANCE_RETRY_BACKOFF_MULTIPLIER = 2.0; /// - /// Smallest amount the probing binary search will halve down to before giving up. + /// Smallest amount a rebalance attempt will shrink down to before giving up (the floor + /// for the per-attempt amount backoff). /// - public static long REBALANCE_MIN_PROBE_AMOUNT_SATS = 10_000; + public static long REBALANCE_MIN_AMOUNT_SATS = 10_000; /// - /// Multiplier applied to the probe amount after each failure. Range: (0, 1) exclusive. - /// 0.5 (default) = halve the amount each iteration; 0.8 = next try is 20% smaller - /// (more iterations, finer granularity); 0.3 = next try is 70% smaller (fewer - /// iterations, gives up faster). + /// Multiplier applied to the rebalanced amount on each retry attempt. Range: (0, 1]. + /// 1 = never shrink (every attempt retries the full requested amount); 0.8 = each retry + /// is 20% smaller than the previous attempt (partial rebalancing); 0.5 = halve each time. + /// The amount for attempt n is RequestedAmountSats × ratio^(n-1), floored at + /// REBALANCE_MIN_AMOUNT_SATS. /// - public static double REBALANCE_PROBE_BACKOFF_RATIO = 0.8; + public static double REBALANCE_AMOUNT_BACKOFF_RATIO = 0.8; /// - /// Maximum number of QueryRoutes-returned routes the prober will try per amount level. + /// 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. /// - public static int REBALANCE_MAX_PROBE_ROUTES_PER_AMOUNT = 6; + public static uint REBALANCE_MAX_PARTS = 32; /// /// How far back the MonitorRebalancesJob sweeps for already-terminal-but-possibly-wrong @@ -428,14 +431,14 @@ static Constants() var rebBackoff = Environment.GetEnvironmentVariable("REBALANCE_RETRY_BACKOFF_MULTIPLIER"); if (rebBackoff != null) REBALANCE_RETRY_BACKOFF_MULTIPLIER = double.Parse(rebBackoff, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture); - var rebMinProbe = Environment.GetEnvironmentVariable("REBALANCE_MIN_PROBE_AMOUNT_SATS"); - if (rebMinProbe != null) REBALANCE_MIN_PROBE_AMOUNT_SATS = long.Parse(rebMinProbe); + var rebMinAmount = Environment.GetEnvironmentVariable("REBALANCE_MIN_AMOUNT_SATS"); + if (rebMinAmount != null) REBALANCE_MIN_AMOUNT_SATS = long.Parse(rebMinAmount); - var rebProbeBackoff = Environment.GetEnvironmentVariable("REBALANCE_PROBE_BACKOFF_RATIO"); - if (rebProbeBackoff != null) REBALANCE_PROBE_BACKOFF_RATIO = double.Parse(rebProbeBackoff, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture); + var rebProbeBackoff = Environment.GetEnvironmentVariable("REBALANCE_AMOUNT_BACKOFF_RATIO"); + if (rebProbeBackoff != null) REBALANCE_AMOUNT_BACKOFF_RATIO = double.Parse(rebProbeBackoff, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture); - var rebMaxProbeRoutes = Environment.GetEnvironmentVariable("REBALANCE_MAX_PROBE_ROUTES_PER_AMOUNT"); - if (rebMaxProbeRoutes != null) REBALANCE_MAX_PROBE_ROUTES_PER_AMOUNT = int.Parse(rebMaxProbeRoutes); + var rebMaxParts = Environment.GetEnvironmentVariable("REBALANCE_MAX_PARTS"); + if (rebMaxParts != null) REBALANCE_MAX_PARTS = uint.Parse(rebMaxParts); var rebReconcileWindow = Environment.GetEnvironmentVariable("REBALANCE_RECONCILE_TERMINAL_WINDOW_HOURS"); if (rebReconcileWindow != null) REBALANCE_RECONCILE_TERMINAL_WINDOW_HOURS = int.Parse(rebReconcileWindow); diff --git a/src/Jobs/MonitorRebalancesJob.cs b/src/Jobs/MonitorRebalancesJob.cs index 936f6388..dce79440 100644 --- a/src/Jobs/MonitorRebalancesJob.cs +++ b/src/Jobs/MonitorRebalancesJob.cs @@ -125,11 +125,11 @@ private async Task ReconcileAsync(Rebalance rebalance, CancellationToken ct) // LND returned NotFound (or the call errored). The right action depends on which // non-terminal state the row is in: // - // - Pending/Probing: the local ExecuteAsync persisted PaymentHashHex right after - // AddInvoice, but SendPaymentV2 doesn't fire until after the probe succeeds — - // so "no record in LND" is the EXPECTED state during this window. Flipping the - // row to Failed here would kill an in-progress probe. Leave it alone; the local - // execution is the source of truth until it dispatches SendPaymentV2. + // - Pending: the local ExecuteAsync persisted PaymentHashHex right after AddInvoice, + // but SendPaymentV2 hasn't fired yet — so "no record in LND" is the EXPECTED state + // during this window. Flipping the row to Failed here would kill an in-progress + // attempt. Leave it alone; the local execution is the source of truth until it + // dispatches SendPaymentV2. // // - InFlight: SendpaymentV2 has been invoked, so LND should know. // If it doesn't, the payment never reached LND (process crashed mid-dispatch, @@ -157,7 +157,7 @@ await _auditService.LogSystemAsync( NewStatus = rebalance.Status.ToString(), }); } - else if (rebalance.Status is RebalanceStatus.Pending or RebalanceStatus.Probing) + else if (rebalance.Status is RebalanceStatus.Pending) { var window = TimeSpan.FromHours(Constants.REBALANCE_RECONCILE_TERMINAL_WINDOW_HOURS); if (rebalance.UpdateDatetime < DateTimeOffset.UtcNow - window) @@ -198,7 +198,7 @@ await _auditService.LogSystemAsync( return; } - if (rebalance.Status is RebalanceStatus.Pending or RebalanceStatus.Probing) + if (rebalance.Status is RebalanceStatus.Pending) { _logger.LogDebug( "Rebalance {RebalanceId} (status={Status}) has a payment record in LND (status={LndStatus}, hash={PaymentHashHex}); local execution still owns it, leaving as-is", diff --git a/src/Migrations/20260630104437_RenameProbeBackoffRatioToAmountBackoffRatio.Designer.cs b/src/Migrations/20260630104437_RenameProbeBackoffRatioToAmountBackoffRatio.Designer.cs new file mode 100644 index 00000000..2748969a --- /dev/null +++ b/src/Migrations/20260630104437_RenameProbeBackoffRatioToAmountBackoffRatio.Designer.cs @@ -0,0 +1,1739 @@ +// +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("20260630104437_RenameProbeBackoffRatioToAmountBackoffRatio")] + partial class RenameProbeBackoffRatioToAmountBackoffRatio + { + /// + 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("IsAutomatedLiquidityEnabled") + .HasColumnType("boolean"); + + b.Property("IsPrivate") + .HasColumnType("boolean"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceNodeId") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DestinationNodeId"); + + b.HasIndex("SourceNodeId"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("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.ToTable("ChannelOperationRequestPSBTs"); + }); + + 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("AutoLiquidityManagementEnabled") + .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("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("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("SwapBudgetRefreshInterval") + .HasColumnType("interval"); + + b.Property("SwapBudgetSats") + .HasColumnType("bigint"); + + b.Property("SwapBudgetStartDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("SwapMaxAmountSats") + .HasColumnType("bigint"); + + b.Property("SwapMinAmountSats") + .HasColumnType("bigint"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FundsDestinationWalletId"); + + b.HasIndex("PubKey") + .IsUnique(); + + b.ToTable("Nodes"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Rebalance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AmountBackoffRatio") + .HasColumnType("double precision"); + + b.Property("AttemptNumber") + .HasColumnType("integer"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("FeePaidMsat") + .HasColumnType("bigint"); + + b.Property("FeePaidSats") + .HasColumnType("bigint"); + + b.Property("IsManual") + .HasColumnType("boolean"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("MaxFeePct") + .HasColumnType("double precision"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("PaymentHashHex") + .HasColumnType("text"); + + b.Property("PaymentRequest") + .HasColumnType("text"); + + b.Property("PreimageHex") + .HasColumnType("text"); + + b.Property("RequestedAmountSats") + .HasColumnType("bigint"); + + b.Property("RetryMaxFeePct") + .HasColumnType("double precision"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("SourceChanIdLnd") + .HasColumnType("numeric(20,0)"); + + b.Property("SourceChannelId") + .HasColumnType("integer"); + + b.Property("SourceNodePubKey") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TargetPubkey") + .HasColumnType("text"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("NodeId"); + + b.HasIndex("SourceChannelId"); + + b.HasIndex("UserRequestorId"); + + b.ToTable("Rebalances"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.SwapOut", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("DestinationWalletId") + .HasColumnType("integer"); + + b.Property("ErrorDetails") + .HasColumnType("text"); + + b.Property("IsManual") + .HasColumnType("boolean"); + + b.Property("LightningFeeSats") + .HasColumnType("bigint"); + + b.Property("NodeId") + .HasColumnType("integer"); + + b.Property("OnChainFeeSats") + .HasColumnType("bigint"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("ProviderId") + .HasColumnType("text"); + + b.Property("SatsAmount") + .HasColumnType("bigint"); + + b.Property("ServiceFeeSats") + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TxId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("UserRequestorId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DestinationWalletId"); + + b.HasIndex("NodeId"); + + b.HasIndex("UserRequestorId"); + + b.ToTable("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.UTXOTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("Outpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key", "Outpoint") + .IsUnique(); + + b.ToTable("UTXOTags"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BIP39Seedphrase") + .HasColumnType("text"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ImportedOutputDescriptor") + .HasColumnType("text"); + + b.Property("InternalWalletId") + .HasColumnType("integer"); + + b.Property("InternalWalletMasterFingerprint") + .HasColumnType("text"); + + b.Property("InternalWalletSubDerivationPath") + .HasColumnType("text"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("IsBIP39Imported") + .HasColumnType("boolean"); + + b.Property("IsCompromised") + .HasColumnType("boolean"); + + b.Property("IsFinalised") + .HasColumnType("boolean"); + + b.Property("IsHotWallet") + .HasColumnType("boolean"); + + b.Property("IsUnSortedMultiSig") + .HasColumnType("boolean"); + + b.Property("MofN") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReferenceId") + .HasColumnType("text"); + + b.Property("UpdateDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("WalletAddressType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("InternalWalletId"); + + b.HasIndex("InternalWalletSubDerivationPath", "InternalWalletMasterFingerprint") + .IsUnique(); + + b.ToTable("Wallets"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BumpingWalletWithdrawalRequestId") + .HasColumnType("integer"); + + b.Property("Changeless") + .HasColumnType("boolean"); + + b.Property("CreationDatetime") + .HasColumnType("timestamp with time zone"); + + b.Property("CustomFeeRate") + .HasColumnType("numeric"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + 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.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.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.Key", b => + { + b.HasOne("NodeGuard.Data.Models.InternalWallet", "InternalWallet") + .WithMany() + .HasForeignKey("InternalWalletId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "User") + .WithMany("Keys") + .HasForeignKey("UserId"); + + b.Navigation("InternalWallet"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.LiquidityRule", b => + { + b.HasOne("NodeGuard.Data.Models.Channel", "Channel") + .WithMany("LiquidityRules") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Wallet", "ReverseSwapWallet") + .WithMany("LiquidityRulesAsReverseSwapWallet") + .HasForeignKey("ReverseSwapWalletId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "SwapWallet") + .WithMany("LiquidityRulesAsSwapWallet") + .HasForeignKey("SwapWalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Channel"); + + b.Navigation("Node"); + + b.Navigation("ReverseSwapWallet"); + + b.Navigation("SwapWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.HasOne("NodeGuard.Data.Models.Wallet", "FundsDestinationWallet") + .WithMany() + .HasForeignKey("FundsDestinationWalletId"); + + b.Navigation("FundsDestinationWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Rebalance", b => + { + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany() + .HasForeignKey("NodeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NodeGuard.Data.Models.Channel", "SourceChannel") + .WithMany() + .HasForeignKey("SourceChannelId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany() + .HasForeignKey("UserRequestorId"); + + b.Navigation("Node"); + + b.Navigation("SourceChannel"); + + b.Navigation("UserRequestor"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.SwapOut", b => + { + b.HasOne("NodeGuard.Data.Models.Wallet", "DestinationWallet") + .WithMany("SwapOuts") + .HasForeignKey("DestinationWalletId"); + + b.HasOne("NodeGuard.Data.Models.Node", "Node") + .WithMany("SwapOuts") + .HasForeignKey("NodeId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany() + .HasForeignKey("UserRequestorId"); + + b.Navigation("DestinationWallet"); + + b.Navigation("Node"); + + b.Navigation("UserRequestor"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.HasOne("NodeGuard.Data.Models.InternalWallet", "InternalWallet") + .WithMany() + .HasForeignKey("InternalWalletId"); + + b.Navigation("InternalWallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "BumpingWalletWithdrawalRequest") + .WithMany() + .HasForeignKey("BumpingWalletWithdrawalRequestId"); + + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "UserRequestor") + .WithMany("WalletWithdrawalRequests") + .HasForeignKey("UserRequestorId"); + + b.HasOne("NodeGuard.Data.Models.Wallet", "Wallet") + .WithMany() + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("BumpingWalletWithdrawalRequest"); + + b.Navigation("UserRequestor"); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestDestination", b => + { + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "WalletWithdrawalRequest") + .WithMany("WalletWithdrawalRequestDestinations") + .HasForeignKey("WalletWithdrawalRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("WalletWithdrawalRequest"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequestPSBT", b => + { + b.HasOne("NodeGuard.Data.Models.ApplicationUser", "Signer") + .WithMany() + .HasForeignKey("SignerId"); + + b.HasOne("NodeGuard.Data.Models.WalletWithdrawalRequest", "WalletWithdrawalRequest") + .WithMany("WalletWithdrawalRequestPSBTs") + .HasForeignKey("WalletWithdrawalRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Signer"); + + b.Navigation("WalletWithdrawalRequest"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Channel", b => + { + b.Navigation("ChannelOperationRequests"); + + b.Navigation("LiquidityRules"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ChannelOperationRequest", b => + { + b.Navigation("ChannelOperationRequestPsbts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Node", b => + { + b.Navigation("ChannelOperationRequestsAsDestination"); + + b.Navigation("ChannelOperationRequestsAsSource"); + + b.Navigation("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.Wallet", b => + { + b.Navigation("ChannelOperationRequestsAsSource"); + + b.Navigation("LiquidityRulesAsReverseSwapWallet"); + + b.Navigation("LiquidityRulesAsSwapWallet"); + + b.Navigation("SwapOuts"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.WalletWithdrawalRequest", b => + { + b.Navigation("WalletWithdrawalRequestDestinations"); + + b.Navigation("WalletWithdrawalRequestPSBTs"); + }); + + modelBuilder.Entity("NodeGuard.Data.Models.ApplicationUser", b => + { + b.Navigation("ChannelOperationRequests"); + + b.Navigation("Keys"); + + b.Navigation("WalletWithdrawalRequests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Migrations/20260630104437_RenameProbeBackoffRatioToAmountBackoffRatio.cs b/src/Migrations/20260630104437_RenameProbeBackoffRatioToAmountBackoffRatio.cs new file mode 100644 index 00000000..f7f0d93b --- /dev/null +++ b/src/Migrations/20260630104437_RenameProbeBackoffRatioToAmountBackoffRatio.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NodeGuard.Migrations +{ + /// + public partial class RenameProbeBackoffRatioToAmountBackoffRatio : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "ProbeBackoffRatio", + table: "Rebalances", + newName: "AmountBackoffRatio"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "AmountBackoffRatio", + table: "Rebalances", + newName: "ProbeBackoffRatio"); + } + } +} diff --git a/src/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Migrations/ApplicationDbContextModelSnapshot.cs index ebb65984..bcba49cf 100644 --- a/src/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Migrations/ApplicationDbContextModelSnapshot.cs @@ -932,6 +932,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + b.Property("AmountBackoffRatio") + .HasColumnType("double precision"); + b.Property("AttemptNumber") .HasColumnType("integer"); @@ -965,9 +968,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("PreimageHex") .HasColumnType("text"); - b.Property("ProbeBackoffRatio") - .HasColumnType("double precision"); - b.Property("RequestedAmountSats") .HasColumnType("bigint"); diff --git a/src/NodeGuard.csproj b/src/NodeGuard.csproj index 7770de48..eaa6c42b 100644 --- a/src/NodeGuard.csproj +++ b/src/NodeGuard.csproj @@ -72,7 +72,7 @@ - + diff --git a/src/Pages/Rebalances.razor b/src/Pages/Rebalances.razor index 427df370..fcd5bd4b 100644 --- a/src/Pages/Rebalances.razor +++ b/src/Pages/Rebalances.razor @@ -148,7 +148,7 @@ @if (!string.IsNullOrEmpty(context.TargetPubkey)) { - @StringHelper.TruncateTail(context.TargetPubkey, 10) + @PeerLabel(context.TargetPubkey) } else @@ -229,7 +229,12 @@ @inject IRebalanceRepository RebalanceRepository @inject IApplicationUserRepository ApplicationUserRepository @inject IToastService ToastService +@inject ILightningService LightningService +@inject ILogger Logger @code { + // Friendly label per target pubkey for the "Target Peer" column: managed node name > LN alias + // > short pubkey. Populated on data load so the (sync) column template can read it. + private Dictionary _peerLabels = new(); [CascadingParameter] private ApplicationUser? LoggedUser { get; set; } private DataGrid _dataGrid = new(); @@ -297,8 +302,47 @@ _rebalances = rows; _totalItems = total; + await ResolvePeerLabelsAsync(rows); } + // Resolves each distinct target pubkey to a human-readable label: a managed node's name if we + // operate it (instant), otherwise its Lightning alias (best-effort), else null (column then + // falls back to the short pubkey). + private async Task ResolvePeerLabelsAsync(IEnumerable rows) + { + var pubkeys = rows + .Select(r => r.TargetPubkey) + .Where(pk => !string.IsNullOrEmpty(pk)) + .Distinct() + .ToList(); + + foreach (var pk in pubkeys) + { + if (_peerLabels.ContainsKey(pk!)) continue; + + var managedName = _availableNodes.FirstOrDefault(n => n.PubKey == pk)?.Name; + if (!string.IsNullOrWhiteSpace(managedName)) + { + _peerLabels[pk!] = managedName!; + continue; + } + + try + { + var alias = (await LightningService.GetNodeInfo(pk!))?.Alias; + if (!string.IsNullOrWhiteSpace(alias)) _peerLabels[pk!] = alias!; + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Could not resolve alias for target peer {Pubkey}", pk); + } + } + } + + // Label for the Target Peer column: resolved name/alias if known, else the short pubkey. + private string PeerLabel(string pubkey) + => _peerLabels.TryGetValue(pubkey, out var label) ? label : StringHelper.TruncateTail(pubkey, 10); + private async Task OnFiltersChanged() => await _dataGrid.Reload(); private async Task ClearAllFilters() diff --git a/src/Proto/nodeguard.proto b/src/Proto/nodeguard.proto index 59934937..a601c362 100644 --- a/src/Proto/nodeguard.proto +++ b/src/Proto/nodeguard.proto @@ -491,8 +491,8 @@ message SetChannelFeePolicyResponse { } enum REBALANCE_STATUS { + reserved 1; // was REBALANCE_PROBING — removed (no probing anymore); number reserved. REBALANCE_PENDING = 0; - REBALANCE_PROBING = 1; REBALANCE_IN_FLIGHT = 2; REBALANCE_SUCCEEDED = 3; REBALANCE_FAILED = 4; @@ -519,11 +519,12 @@ message RequestRebalanceRequest { optional string target_pubkey = 6; // Pathfinding/payment timeout in seconds; 0 → server default (60s) int32 timeout_seconds = 7; - // Multiplier applied to the probe amount after each failure. Range: (0, 1) exclusive. - // 0.5 = halve (default); 0.8 = next try is 20% smaller (finer-grained, more iterations); - // 0.3 = next try is 70% smaller (gives up faster). If unset, falls back to - // Constants.REBALANCE_PROBE_BACKOFF_RATIO. - optional double probe_backoff_ratio = 8; + // Multiplier applied to the rebalanced amount on each retry attempt. Range: (0, 1]. + // 1 = never shrink (every attempt retries the full amount); 0.8 = each retry is 20% + // smaller; 0.5 = halve each time. The amount for attempt n is amount_sats * ratio^(n-1), + // floored at REBALANCE_MIN_AMOUNT_SATS. If unset, falls back to + // Constants.REBALANCE_AMOUNT_BACKOFF_RATIO. + optional double amount_backoff_ratio = 8; // Maximum number of attempts (including the first try). Must be >= 1. // If unset, falls back to Constants.REBALANCE_MAX_ATTEMPTS optional int32 max_attempts = 9; @@ -559,7 +560,7 @@ message GetRebalanceResponse { optional int64 effective_ppm = 11; optional uint64 source_chan_id = 12; optional string target_pubkey = 13; - optional double probe_backoff_ratio = 14; + optional double amount_backoff_ratio = 14; optional int32 max_attempts = 15; int64 creation_datetime_unix = 16; int64 update_datetime_unix = 17; diff --git a/src/Rpc/NodeGuardService.cs b/src/Rpc/NodeGuardService.cs index 7860291f..9dd7995b 100644 --- a/src/Rpc/NodeGuardService.cs +++ b/src/Rpc/NodeGuardService.cs @@ -1231,7 +1231,7 @@ public override async Task RequestRebalance(RequestReb TimeoutSeconds: request.TimeoutSeconds, IsManual: true, UserRequestorId: null, - ProbeBackoffRatio: request.HasProbeBackoffRatio ? request.ProbeBackoffRatio : null, + AmountBackoffRatio: request.HasAmountBackoffRatio ? request.AmountBackoffRatio : null, MaxAttempts: request.HasMaxAttempts ? request.MaxAttempts : null, RetryMaxFeePct: request.HasRetryMaxFeePct ? request.RetryMaxFeePct : null); @@ -1330,7 +1330,7 @@ private static GetRebalanceResponse MapRebalance(Rebalance rebalance) if (rebalance.EffectivePpm.HasValue) response.EffectivePpm = rebalance.EffectivePpm.Value; if (rebalance.SourceChanIdLnd.HasValue) response.SourceChanId = rebalance.SourceChanIdLnd.Value; if (rebalance.TargetPubkey != null) response.TargetPubkey = rebalance.TargetPubkey; - if (rebalance.ProbeBackoffRatio.HasValue) response.ProbeBackoffRatio = rebalance.ProbeBackoffRatio.Value; + if (rebalance.AmountBackoffRatio.HasValue) response.AmountBackoffRatio = rebalance.AmountBackoffRatio.Value; if (rebalance.MaxAttempts.HasValue) response.MaxAttempts = rebalance.MaxAttempts.Value; return response; } @@ -1338,7 +1338,6 @@ private static GetRebalanceResponse MapRebalance(Rebalance rebalance) private static REBALANCE_STATUS MapRebalanceStatus(RebalanceStatus status) => status switch { RebalanceStatus.Pending => REBALANCE_STATUS.RebalancePending, - RebalanceStatus.Probing => REBALANCE_STATUS.RebalanceProbing, RebalanceStatus.InFlight => REBALANCE_STATUS.RebalanceInFlight, RebalanceStatus.Succeeded => REBALANCE_STATUS.RebalanceSucceeded, RebalanceStatus.Failed => REBALANCE_STATUS.RebalanceFailed, @@ -1352,7 +1351,6 @@ private static GetRebalanceResponse MapRebalance(Rebalance rebalance) private static RebalanceStatus MapRebalanceStatus(REBALANCE_STATUS status) => status switch { REBALANCE_STATUS.RebalancePending => RebalanceStatus.Pending, - REBALANCE_STATUS.RebalanceProbing => RebalanceStatus.Probing, REBALANCE_STATUS.RebalanceInFlight => RebalanceStatus.InFlight, REBALANCE_STATUS.RebalanceSucceeded => RebalanceStatus.Succeeded, REBALANCE_STATUS.RebalanceFailed => RebalanceStatus.Failed, diff --git a/src/Services/LightningService.cs b/src/Services/LightningService.cs index 2d1c7dfb..04e6c045 100644 --- a/src/Services/LightningService.cs +++ b/src/Services/LightningService.cs @@ -150,23 +150,15 @@ public interface ILightningService /// Task AddInvoiceAsync(Node node, long amountSats, string memo, long expirySeconds); - /// - /// Probes a candidate route at the requested amount, reducing the amount until either a route - /// works (signaled by IncorrectOrUnknownPaymentDetails at the last hop) or the - /// minimum probe amount is reached. Returns the probed amount and the validated - /// route, or NoRoute if no route was found. - /// - Task ProbeRouteAsync(Node node, long amountSats, long feeLimitMsat, - ulong? outgoingChanId, string? lastHopPubkeyHex, double probeBackoffRatio, CancellationToken ct); - /// /// Sends a self-payment via Routerrpc.SendPaymentV2 with the given constraints and /// drains the streaming response to the terminal Payment update. LND drives MPP and /// internal route iteration / retries; we provide the cost cap (feeLimitMsat) and /// the optional last-hop peer pin (lastHopPubkeyHex; null lets LND pick any inbound - /// peer). The dest is implicit in paymentRequest (self-issued invoice). + /// peer). The dest is implicit in paymentRequest (self-issued invoice). The invoice is + /// amountless, so sets the amount paid on this attempt. /// - Task SendPaymentV2Async(Node node, string paymentRequest, long feeLimitMsat, + Task SendPaymentV2Async(Node node, string paymentRequest, long amountSats, long feeLimitMsat, ulong[]? outgoingChanIds, string? lastHopPubkeyHex, int timeoutSeconds, CancellationToken ct); /// @@ -210,15 +202,6 @@ Task SendPaymentV2Async(Node node, string paymentRequest, long feeLimit public Task<(RoutingPolicy?, RoutingPolicy?)> GetChannelFeePolicy(ulong chanId, Node node); } - /// - /// Result of . - /// - public abstract record ProbeResult - { - public sealed record Success(long AmountSats, Lnrpc.Route Route) : ProbeResult; - public sealed record NoRoute(string? Reason = null) : ProbeResult; - } - public class LightningService : ILightningService { private readonly ILogger _logger; @@ -1639,95 +1622,7 @@ public async Task> GetChannelsState() }); } - public async Task ProbeRouteAsync(Node node, long amountSats, long feeLimitMsat, - ulong? outgoingChanId, string? lastHopPubkeyHex, double probeBackoffRatio, CancellationToken ct) - { - // Ratio must be in the closed interval [0, 1]. 0 zeroes the next try; 1 never shrinks. - var ratio = probeBackoffRatio >= 0.0 && probeBackoffRatio <= 1.0 - ? probeBackoffRatio - : Constants.REBALANCE_PROBE_BACKOFF_RATIO; - var amount = amountSats; - while (amount >= Constants.REBALANCE_MIN_PROBE_AMOUNT_SATS) - { - ct.ThrowIfCancellationRequested(); - - // Scale fee_limit proportionally to the current probe amount so the cap - // tracks the ratio the user accepted at the requested amount. - var scaledFeeLimitMsat = amountSats > 0 - ? (long)((double)feeLimitMsat * amount / amountSats) - : feeLimitMsat; - - var routesRequest = new QueryRoutesRequest - { - Amt = amount, - FeeLimit = new FeeLimit { FixedMsat = scaledFeeLimitMsat }, - }; - - if (outgoingChanId.HasValue) - routesRequest.OutgoingChanIds.Add(outgoingChanId.Value); - - if (!string.IsNullOrEmpty(lastHopPubkeyHex)) - { - routesRequest.LastHopPubkey = ByteString.CopyFrom(Convert.FromHexString(lastHopPubkeyHex)); - routesRequest.PubKey = node.PubKey; // circular: destination is self - } - else - { - // No explicit last hop: dest must still be set; default to self for circular. - routesRequest.PubKey = node.PubKey; - } - - var routesResponse = await _lightningClientService.QueryRoutes(node, routesRequest); - if (routesResponse == null || routesResponse.Routes.Count == 0) - { - amount = (long)(amount * ratio); - continue; - } - - var routesToTry = routesResponse.Routes - .Take(Constants.REBALANCE_MAX_PROBE_ROUTES_PER_AMOUNT) - .ToList(); - - foreach (var route in routesToTry) - { - ct.ThrowIfCancellationRequested(); - - var randomHash = new byte[32]; - RandomNumberGenerator.Fill(randomHash); - - var sendToRouteRequest = new Routerrpc.SendToRouteRequest - { - PaymentHash = ByteString.CopyFrom(randomHash), - Route = route, - }; - - HTLCAttempt? attempt; - try - { - attempt = await _lightningRouterService.SendToRouteV2Async(node, sendToRouteRequest, ct); - } - catch (Exception e) - { - _logger.LogWarning(e, "Probe SendToRouteV2 failed for node {NodeId} amount {Amount}", node.Id, amount); - continue; - } - - if (attempt?.Failure?.Code == Failure.Types.FailureCode.IncorrectOrUnknownPaymentDetails) - { - // The route worked all the way to the destination; the random hash - // failed at the final hop, which is the probe success signal. - return new ProbeResult.Success(amount, route); - } - - // Mid-route failure - skip this route and try the next. - } - - amount = (long)(amount * ratio); - } - - return new ProbeResult.NoRoute("Probe exhausted before reaching minimum amount"); - } - public async Task SendPaymentV2Async(Node node, string paymentRequest, long feeLimitMsat, + public async Task SendPaymentV2Async(Node node, string paymentRequest, long amountSats, long feeLimitMsat, ulong[]? outgoingChanIds, string? lastHopPubkeyHex, int timeoutSeconds, CancellationToken ct) { if (string.IsNullOrEmpty(paymentRequest)) @@ -1736,9 +1631,11 @@ public async Task SendPaymentV2Async(Node node, string paymentRequest, var request = new SendPaymentRequest { PaymentRequest = paymentRequest, + Amt = amountSats, // amountless invoice: the amount is set per attempt here FeeLimitMsat = feeLimitMsat, TimeoutSeconds = timeoutSeconds > 0 ? timeoutSeconds : 60, AllowSelfPayment = true, // mandatory for circular self-pay + MaxParts = Constants.REBALANCE_MAX_PARTS, // let LND split (MPP) across routes NoInflightUpdates = true, // we only care about the terminal Payment update }; diff --git a/src/Services/RebalanceService.cs b/src/Services/RebalanceService.cs index aec04e02..a1a8eb7c 100644 --- a/src/Services/RebalanceService.cs +++ b/src/Services/RebalanceService.cs @@ -35,7 +35,7 @@ public record RebalanceRequest( int TimeoutSeconds = 60, bool IsManual = true, string? UserRequestorId = null, - double? ProbeBackoffRatio = null, + double? AmountBackoffRatio = null, int? MaxAttempts = null, double? RetryMaxFeePct = null); @@ -89,10 +89,10 @@ public async Task RebalanceAsync(RebalanceRequest request, Cancellati "Rebalance amount is zero — channel is already at or above the requested inbound ratio, no rebalance needed.", nameof(request.AmountSats)); - if (request.ProbeBackoffRatio is { } ratio && (ratio <= 0.0 || ratio >= 1.0)) + if (request.AmountBackoffRatio is { } ratio && (ratio <= 0.0 || ratio > 1.0)) throw new ArgumentException( - "Probe backoff ratio must be in the open interval (0, 1). 0 zeroes the next try; 1 never shrinks.", - nameof(request.ProbeBackoffRatio)); + "Amount backoff ratio must be in the half-open interval (0, 1]. 1 never shrinks (every attempt retries the full amount); values below 1 shrink the amount on each retry.", + nameof(request.AmountBackoffRatio)); if (request.MaxAttempts is { } attempts && attempts <= 0) throw new ArgumentException( @@ -160,7 +160,7 @@ public async Task RebalanceAsync(RebalanceRequest request, Cancellati TargetPubkey = targetPubkey, UserRequestorId = request.UserRequestorId, TimeoutSeconds = request.TimeoutSeconds == 0 ? 60 : request.TimeoutSeconds, - ProbeBackoffRatio = request.ProbeBackoffRatio, + AmountBackoffRatio = request.AmountBackoffRatio, MaxAttempts = request.MaxAttempts, RetryMaxFeePct = request.RetryMaxFeePct, }; @@ -250,100 +250,64 @@ await _auditService.LogAsync(AuditActionType.RebalanceCompleted, AuditEventType. } } - var memo = $"NG rebalance #{rebalance.Id} attempt {rebalance.AttemptNumber}"; - var invoiceExpiry = ComputeInvoiceExpirySeconds(rebalance); - var invoice = await _lightningService.AddInvoiceAsync(node, rebalance.SatsAmount, memo, invoiceExpiry); - if (invoice == null || string.IsNullOrEmpty(invoice.PaymentRequest)) - { - _logger.LogWarning( - "Rebalance {RebalanceId} failed to create self-invoice on node {NodeId}", - rebalance.Id, node.Id); - rebalance.Status = RebalanceStatus.Failed; - _rebalanceRepository.Update(rebalance); - await _auditService.LogAsync(AuditActionType.RebalanceCompleted, AuditEventType.Failure, - AuditObjectType.Rebalance, rebalance.Id.ToString(), - new { Reason = "Failed to create self-invoice" }); - await ScheduleRetryIfEligibleAsync(rebalance); - return rebalance; - } - - // Persist the payment hash before sending so MonitorRebalancesJob can resolve - // the true outcome against LND if this process dies mid-stream. - rebalance.PaymentHashHex = Convert.ToHexString(invoice.RHash.ToByteArray()).ToLowerInvariant(); - _rebalanceRepository.Update(rebalance); - - _logger.LogInformation( - "Rebalance {RebalanceId} self-invoice created (hash={PaymentHashHex}, expirySeconds={ExpirySeconds})", - rebalance.Id, rebalance.PaymentHashHex, invoiceExpiry); - - var feeLimitMsat = ComputeFeeLimitMsat(rebalance.SatsAmount, rebalance.MaxFeePct); + // Per-attempt amount: the full requested amount on attempt 1, shrunk by the backoff + // ratio on each retry (ratio = 1 ⇒ never shrink). The amount rides on SendPaymentV2, + // not the invoice, so partial-amount retries reuse the same amountless invoice. + rebalance.SatsAmount = ComputeAttemptAmountSats(rebalance); - rebalance.Status = RebalanceStatus.Probing; - _rebalanceRepository.Update(rebalance); - _logger.LogInformation( - "Rebalance {RebalanceId} probing for route (amount={AmountSats} sats, feeLimitMsat={FeeLimitMsat}, probeBackoffRatio={ProbeBackoffRatio})", - rebalance.Id, rebalance.SatsAmount, feeLimitMsat, - rebalance.ProbeBackoffRatio ?? Constants.REBALANCE_PROBE_BACKOFF_RATIO); - await _auditService.LogAsync(AuditActionType.RebalanceProbing, AuditEventType.Attempt, - AuditObjectType.Rebalance, rebalance.Id.ToString(), - new { rebalance.AttemptNumber, rebalance.SatsAmount, FeeLimitMsat = feeLimitMsat }); - - var probeBackoffRatio = rebalance.ProbeBackoffRatio ?? Constants.REBALANCE_PROBE_BACKOFF_RATIO; - var probe = await _lightningService.ProbeRouteAsync(node, rebalance.SatsAmount, feeLimitMsat, - rebalance.SourceChanIdLnd, rebalance.TargetPubkey, probeBackoffRatio, ct); - - if (probe is ProbeResult.NoRoute noRoute) + // One amountless self-invoice per rebalance: created on the first attempt and reused + // on every retry, so the whole rebalance settles against a single payment hash. + if (string.IsNullOrEmpty(rebalance.PaymentRequest)) { - _logger.LogInformation( - "Rebalance {RebalanceId} probe returned NoRoute: {Reason}", - rebalance.Id, noRoute.Reason); - rebalance.Status = RebalanceStatus.NoRoute; - _rebalanceRepository.Update(rebalance); - await _auditService.LogAsync(AuditActionType.RebalanceProbing, AuditEventType.Failure, - AuditObjectType.Rebalance, rebalance.Id.ToString(), - new { Reason = noRoute.Reason }); - await _auditService.LogAsync(AuditActionType.RebalanceCompleted, AuditEventType.Failure, - AuditObjectType.Rebalance, rebalance.Id.ToString(), - new { rebalance.Status }); - await ScheduleRetryIfEligibleAsync(rebalance); - return rebalance; - } + var memo = $"NG rebalance #{rebalance.Id}"; + var invoiceExpiry = ComputeInvoiceExpirySeconds(rebalance); + // amountSats: 0 → amountless invoice; the amount is supplied per attempt on SendPaymentV2. + var invoice = await _lightningService.AddInvoiceAsync(node, 0, memo, invoiceExpiry); + if (invoice == null || string.IsNullOrEmpty(invoice.PaymentRequest)) + { + _logger.LogWarning( + "Rebalance {RebalanceId} failed to create self-invoice on node {NodeId}", + rebalance.Id, node.Id); + rebalance.Status = RebalanceStatus.Failed; + _rebalanceRepository.Update(rebalance); + await _auditService.LogAsync(AuditActionType.RebalanceCompleted, AuditEventType.Failure, + AuditObjectType.Rebalance, rebalance.Id.ToString(), + new { Reason = "Failed to create self-invoice" }); + await ScheduleRetryIfEligibleAsync(rebalance); + return rebalance; + } - var success = (ProbeResult.Success)probe; - _logger.LogInformation( - "Rebalance {RebalanceId} probe succeeded (probedAmountSats={ProbedAmountSats}, routeHops={RouteHops})", - rebalance.Id, success.AmountSats, success.Route.Hops.Count); - await _auditService.LogAsync(AuditActionType.RebalanceProbing, AuditEventType.Success, - AuditObjectType.Rebalance, rebalance.Id.ToString(), - new { ProbedAmountSats = success.AmountSats, RouteHops = success.Route.Hops.Count }); + // Persist the invoice + payment hash before sending so MonitorRebalancesJob can + // resolve the true outcome against LND if this process dies mid-stream. + rebalance.PaymentRequest = invoice.PaymentRequest; + rebalance.PaymentHashHex = Convert.ToHexString(invoice.RHash.ToByteArray()).ToLowerInvariant(); + _rebalanceRepository.Update(rebalance); - if (success.AmountSats < rebalance.SatsAmount) - { _logger.LogInformation( - "Rebalance {RebalanceId} probe shrunk amount from {OriginalSats} to {ProbedSats} sats", - rebalance.Id, rebalance.SatsAmount, success.AmountSats); - rebalance.SatsAmount = success.AmountSats; - feeLimitMsat = ComputeFeeLimitMsat(rebalance.SatsAmount, rebalance.MaxFeePct); + "Rebalance {RebalanceId} amountless self-invoice created (hash={PaymentHashHex}, expirySeconds={ExpirySeconds})", + rebalance.Id, rebalance.PaymentHashHex, invoiceExpiry); } - rebalance.Status = RebalanceStatus.InFlight; - _rebalanceRepository.Update(rebalance); + var feeLimitMsat = ComputeFeeLimitMsat(rebalance.SatsAmount, rebalance.MaxFeePct); var outgoingChanIds = rebalance.SourceChanIdLnd.HasValue ? [rebalance.SourceChanIdLnd.Value] : Array.Empty(); + rebalance.Status = RebalanceStatus.InFlight; + _rebalanceRepository.Update(rebalance); + _logger.LogInformation( "Rebalance {RebalanceId} dispatching SendPaymentV2 (amount={AmountSats} sats, feeLimitMsat={FeeLimitMsat}, timeoutSeconds={TimeoutSeconds}, hash={PaymentHashHex})", rebalance.Id, rebalance.SatsAmount, feeLimitMsat, rebalance.TimeoutSeconds, rebalance.PaymentHashHex); - // Probe gave us feasibility + (possibly shrunk) amount; the actual settle goes - // through LND's full pathfinder via SendPaymentV2 — that gives us MPP and - // built-in per-route retries inside one payment, which the SendToRouteV2 path - // didn't. The probed Route itself is informational only. + // No pre-probe: LND's own pathfinder (mission control + MPP + per-route retries + // within the timeout) finds and settles the route. Failures fall through to the + // attempt-level retry/backoff below, which shrinks the amount per AmountBackoffRatio. var payment = await _lightningService.SendPaymentV2Async( node, - invoice.PaymentRequest, + rebalance.PaymentRequest!, + rebalance.SatsAmount, feeLimitMsat, outgoingChanIds, rebalance.TargetPubkey, @@ -432,6 +396,22 @@ private static double ResolveMaxFeePct(double? userSupplied, decimal defaultPct) private static long ComputeFeeLimitMsat(long satsAmount, double maxFeePct) => (long)Math.Round(satsAmount * (decimal)maxFeePct * 10m, MidpointRounding.AwayFromZero); + /// + /// Amount to pay on the current attempt: RequestedAmountSats × ratio^(AttemptNumber-1), + /// clamped to [min(REBALANCE_MIN_AMOUNT_SATS, requested), requested]. Attempt 1 always uses + /// the full requested amount (ratio^0 = 1); each retry shrinks by the backoff ratio + /// (ratio = 1 ⇒ no shrink). The same amountless invoice carries whatever amount this returns. + /// + private static long ComputeAttemptAmountSats(Rebalance rebalance) + { + var ratio = rebalance.AmountBackoffRatio ?? Constants.REBALANCE_AMOUNT_BACKOFF_RATIO; + var scaled = (long)Math.Round( + rebalance.RequestedAmountSats * Math.Pow(ratio, rebalance.AttemptNumber - 1), + MidpointRounding.AwayFromZero); + var floor = Math.Min(Constants.REBALANCE_MIN_AMOUNT_SATS, rebalance.RequestedAmountSats); + return Math.Clamp(scaled, floor, rebalance.RequestedAmountSats); + } + /// /// Sizes the self-invoice's expiry to outlive the full retry window — per-attempt /// payment timeout + every backoff gap between retries + a safety buffer. The probe is diff --git a/src/Shared/NewRebalanceModal.razor b/src/Shared/NewRebalanceModal.razor index bdc1d549..be36985f 100644 --- a/src/Shared/NewRebalanceModal.razor +++ b/src/Shared/NewRebalanceModal.razor @@ -36,6 +36,9 @@ + + Filter by peer (optional) — narrows the channel list below to channels with the chosen counterparty. + - Probe backoff ratio - + Amount backoff ratio + + Max="1m"/> - @($"Server default: {Constants.REBALANCE_PROBE_BACKOFF_RATIO:0.00}. Higher values = finer-grained probing.") + @($"Server default: {Constants.REBALANCE_AMOUNT_BACKOFF_RATIO:0.00}. 1 = always retry the full amount; lower = shrink the amount on each retry.") @@ -290,7 +293,7 @@ private decimal _maxFeePct = Constants.REBALANCE_DEFAULT_MAX_FEE_PCT; private decimal _retryMaxFeePct = Constants.REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT; private int _timeoutSeconds = 60; - private decimal _probeBackoffRatio = (decimal)Constants.REBALANCE_PROBE_BACKOFF_RATIO; + private decimal _amountBackoffRatio = (decimal)Constants.REBALANCE_AMOUNT_BACKOFF_RATIO; private int _maxAttempts = Constants.REBALANCE_MAX_ATTEMPTS; private bool _showAdvanced; private bool _submitting; @@ -348,8 +351,8 @@ && _sourceLocalSats.HasValue && ComputeAmountSats() > 0 && _maxFeePct > 0m - && _probeBackoffRatio > 0m - && _probeBackoffRatio < 1m + && _amountBackoffRatio > 0m + && _amountBackoffRatio <= 1m && _maxAttempts >= 1 && _retryMaxFeePct >= _maxFeePct && !_submitting; @@ -411,7 +414,7 @@ _maxFeePct = Constants.REBALANCE_DEFAULT_MAX_FEE_PCT; _retryMaxFeePct = Constants.REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT; _timeoutSeconds = 60; - _probeBackoffRatio = (decimal)Constants.REBALANCE_PROBE_BACKOFF_RATIO; + _amountBackoffRatio = (decimal)Constants.REBALANCE_AMOUNT_BACKOFF_RATIO; _maxAttempts = Constants.REBALANCE_MAX_ATTEMPTS; _showAdvanced = false; _submitting = false; @@ -448,25 +451,39 @@ Logger.LogWarning(ex, "Could not pre-load channel balances for node {NodeId}", _selectedNode.Id); } - _sourcePeerOptions = _nodeChannels + var peerGroups = _nodeChannels .Select(c => c.SourceNode?.PubKey == _selectedNode.PubKey ? c.DestinationNode : c.SourceNode) .Where(n => n != null && !string.IsNullOrEmpty(n.PubKey)) .GroupBy(n => n!.PubKey) + .ToList(); + + // Friendly label per peer: Lightning alias > NodeGuard node name > short pubkey, + // so the dropdowns read as names rather than raw pubkeys. + var peerLabels = new Dictionary(); + foreach (var g in peerGroups) + { + string? alias = null; + try { alias = (await LightningService.GetNodeInfo(g.Key))?.Alias; } + catch (Exception ex) { Logger.LogDebug(ex, "Could not resolve alias for peer {Pubkey}", g.Key); } + + peerLabels[g.Key] = !string.IsNullOrWhiteSpace(alias) ? alias! + : !string.IsNullOrWhiteSpace(g.First()!.Name) ? g.First()!.Name! + : g.Key[..10]; + } + + _sourcePeerOptions = peerGroups .Select(g => new PeerOption { Pubkey = g.Key, - DisplayName = $"{g.First()!.Name ?? g.Key[..10]} · {g.Count()} ch", + DisplayName = $"{peerLabels[g.Key]} · {g.Count()} ch", }) .ToList(); - _peerOptions = _nodeChannels - .Select(c => c.SourceNode?.PubKey == _selectedNode.PubKey ? c.DestinationNode : c.SourceNode) - .Where(n => n != null && !string.IsNullOrEmpty(n.PubKey)) - .GroupBy(n => n!.PubKey) + _peerOptions = peerGroups .Select(g => new PeerOption { Pubkey = g.Key, - DisplayName = $"{g.First()!.Name ?? g.Key[..10]} · {g.Count()} channel{(g.Count() == 1 ? "" : "s")}", + DisplayName = $"{peerLabels[g.Key]} · {g.Count()} channel{(g.Count() == 1 ? "" : "s")}", }) .ToList(); } @@ -590,7 +607,7 @@ TimeoutSeconds: _timeoutSeconds, IsManual: true, UserRequestorId: LoggedUser.Id, - ProbeBackoffRatio: _probeBackoffRatio > 0m && _probeBackoffRatio < 1m ? (double)_probeBackoffRatio : (double?)null, + AmountBackoffRatio: _amountBackoffRatio > 0m && _amountBackoffRatio <= 1m ? (double)_amountBackoffRatio : (double?)null, MaxAttempts: _maxAttempts >= 1 ? _maxAttempts : (int?)null, RetryMaxFeePct: _retryMaxFeePct > 0m ? (double)_retryMaxFeePct : (double?)null); diff --git a/test/NodeGuard.Tests/E2E/E2EFactAttribute.cs b/test/NodeGuard.Tests/E2E/E2EFactAttribute.cs new file mode 100644 index 00000000..3c02317c --- /dev/null +++ b/test/NodeGuard.Tests/E2E/E2EFactAttribute.cs @@ -0,0 +1,56 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using System.Net.Sockets; + +namespace NodeGuard.Tests.E2E; + +/// +/// Gates e2e tests so they RUN when a NodeGuard gRPC server is reachable (so a normal +/// dotnet test / VSCode Test Explorer run auto-executes them whenever a local NodeGuard +/// is up on 50051), and otherwise report as Skipped rather than failing. +/// +/// Runs when EITHER: +/// • RUN_E2E_TESTS=1 (explicit force — used by CI and the e2e container), OR +/// • the gRPC endpoint (NODEGUARD_GRPC_ENDPOINT, default http://localhost:50051) accepts a TCP connection. +/// +public sealed class E2EFactAttribute : FactAttribute +{ + public E2EFactAttribute() + { + if (Environment.GetEnvironmentVariable("RUN_E2E_TESTS") == "1") return; + if (NodeGuardReachable()) return; + Skip = "No NodeGuard gRPC reachable (NODEGUARD_GRPC_ENDPOINT, default localhost:50051) and RUN_E2E_TESTS != 1 — skipping e2e."; + } + + private static bool NodeGuardReachable() + { + try + { + var endpoint = Environment.GetEnvironmentVariable("NODEGUARD_GRPC_ENDPOINT") ?? "http://localhost:50051"; + var uri = new Uri(endpoint); + using var client = new TcpClient(); + return client.ConnectAsync(uri.Host, uri.Port).Wait(TimeSpan.FromMilliseconds(500)) && client.Connected; + } + catch + { + return false; + } + } +} diff --git a/test/NodeGuard.Tests/E2E/RebalanceE2ETests.cs b/test/NodeGuard.Tests/E2E/RebalanceE2ETests.cs new file mode 100644 index 00000000..90f33b35 --- /dev/null +++ b/test/NodeGuard.Tests/E2E/RebalanceE2ETests.cs @@ -0,0 +1,175 @@ +/* + * NodeGuard + * Copyright (C) 2023 Elenpay + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +using System.Net; +using FluentAssertions; +using Grpc.Core; +using Grpc.Net.Client; +using NBitcoin; +using NBitcoin.RPC; +using Nodeguard; +using Xunit.Abstractions; + +namespace NodeGuard.Tests.E2E; + +/// +/// True end-to-end test driven entirely from .NET (no grpcurl/curl): a generated gRPC client +/// drives a LIVE NodeGuard instance through the whole option-B flow — +/// GetNodes → OpenChannel(Alice→Bob) → mine (NBitcoin RPC) + poll GetChannelOperationRequest +/// until the channel confirms → RequestRebalance(Alice→Bob→Carol→Alice) → assert success. +/// This exercises gRPC auth, channel opening (wallet → PSBT → internal signing → broadcast), +/// channel sync, and the amountless-invoice rebalance against real LND + Postgres. +/// +/// Gated by (RUN_E2E_TESTS=1). Connection via env: +/// NODEGUARD_GRPC_ENDPOINT default http://localhost:50051 (h2c) +/// NODEGUARD_API_TOKEN default the dev "Liquidator" token +/// BITCOIND_RPC_URL/USER/PASS/WALLET default http://localhost:18443 / polaruser / polarpass / default +/// E2E_HOT_WALLET_ID NodeGuard hot wallet to fund the channel (default 3) +/// +[Trait("Category", "E2E")] +public class RebalanceE2ETests +{ + private const string DefaultDevToken = "8rvSsUGeyXXdDQrHctcTey/xtHdZQEn945KHwccKp9Q="; + + private readonly ITestOutputHelper _output; + + public RebalanceE2ETests(ITestOutputHelper output) + { + _output = output; + AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); + } + + [E2EFact] + public async Task OpenChannelViaGrpc_ThenCircularRebalance_Succeeds() + { + var client = CreateClient(out var headers); + var rpc = CreateBitcoindRpc(); + + // 0. Wait for NodeGuard to be up and to have seeded the three nodes. + var nodes = await WaitForNodesAsync(client, headers); + var alice = nodes.Single(n => n.Name == "alice"); + var bob = nodes.Single(n => n.Name == "bob"); + var carol = nodes.Single(n => n.Name == "carol"); + _output.WriteLine($"alice={alice.PubKey} bob={bob.PubKey} carol={carol.PubKey}"); + + // 1. Open Alice→Bob THROUGH NodeGuard (option B). Retry briefly in case the dev hot + // wallet is still being funded by DbInitializer when we connect. + var walletId = int.Parse(Env("E2E_HOT_WALLET_ID", "3")); + var openReq = new OpenChannelRequest + { + SourcePubKey = alice.PubKey, + DestinationPubKey = bob.PubKey, + WalletId = walletId, + SatsAmount = 16_000_000, + Private = false, + Changeless = false, + MempoolFeeRate = FEES_TYPE.CustomFee, + CustomFeeRate = 2, + }; + var opId = await RetryAsync( + async () => (await client.OpenChannelAsync(openReq, headers)).ChannelOperationRequestId, + attempts: 10, delay: TimeSpan.FromSeconds(6), what: "OpenChannel"); + _output.WriteLine($"OpenChannel → operation {opId}"); + + // 2. Mine + poll until the funding tx confirms and NodeGuard records the channel id. + long channelId = 0; + for (var i = 0; i < 40 && channelId == 0; i++) + { + await MineAsync(rpc, 2); + var st = await client.GetChannelOperationRequestAsync( + new GetChannelOperationRequestRequest { ChannelOperationRequestId = opId }, headers); + _output.WriteLine($"poll {i}: status={st.Status} channelId={(st.HasChannelId ? st.ChannelId : 0)}"); + if (st.HasChannelId && st.ChannelId > 0) channelId = st.ChannelId; + else await Task.Delay(TimeSpan.FromSeconds(3)); + } + channelId.Should().BeGreaterThan(0, "NodeGuard should record the opened channel's id"); + + // 3. Mine a few more blocks so LND marks the channel active and gossip propagates. + await MineAsync(rpc, 6); + await Task.Delay(TimeSpan.FromSeconds(4)); + + // 4. Circular rebalance Alice→Bob→Carol→Alice over the just-opened channel. + var resp = await client.RequestRebalanceAsync(new RequestRebalanceRequest + { + NodePubkey = alice.PubKey, + SourceChannelId = (int)channelId, + TargetPubkey = carol.PubKey, + AmountSats = 500_000, + MaxFeePct = 0.2, + MaxAttempts = 1, + }, headers); + + _output.WriteLine($"rebalance {resp.RebalanceId}: status={resp.Status} feeSats={resp.FeePaidSats} ppm={resp.EffectivePpm}"); + resp.Status.Should().Be(REBALANCE_STATUS.RebalanceSucceeded); + resp.FeePaidSats.Should().BeGreaterThan(0); + resp.FeePaidSats.Should().BeLessThanOrEqualTo(1_000); // within 0.2% of 500k + } + + // ---- helpers ----------------------------------------------------------------------------- + + private async Task> WaitForNodesAsync( + NodeGuardService.NodeGuardServiceClient client, Metadata headers) + { + return await RetryAsync(async () => + { + var resp = await client.GetNodesAsync(new GetNodesRequest(), headers); + var seeded = resp.Nodes.Where(n => n.Name is "alice" or "bob" or "carol").ToList(); + if (seeded.Count < 3) throw new InvalidOperationException($"only {seeded.Count}/3 nodes seeded"); + return (IReadOnlyList)seeded; + // Generous window: a fresh NodeGuard runs migrations + funds its wallet (mining + NBXplorer + // sync) before serving gRPC, which can take several minutes. + }, attempts: 90, delay: TimeSpan.FromSeconds(4), what: "GetNodes (NodeGuard readiness)"); + } + + private async Task MineAsync(RPCClient rpc, int blocks) + { + var addr = await rpc.GetNewAddressAsync(); + await rpc.GenerateToAddressAsync(blocks, addr); + } + + private async Task RetryAsync(Func> action, int attempts, TimeSpan delay, string what) + { + Exception? last = null; + for (var i = 0; i < attempts; i++) + { + try { return await action(); } + catch (Exception ex) { last = ex; _output.WriteLine($"{what} attempt {i + 1}/{attempts} failed: {ex.Message}"); } + await Task.Delay(delay); + } + throw new InvalidOperationException($"{what} did not succeed after {attempts} attempts", last); + } + + private static NodeGuardService.NodeGuardServiceClient CreateClient(out Metadata headers) + { + var endpoint = Env("NODEGUARD_GRPC_ENDPOINT", "http://localhost:50051"); + headers = new Metadata { { "auth-token", Env("NODEGUARD_API_TOKEN", DefaultDevToken) } }; + return new NodeGuardService.NodeGuardServiceClient(GrpcChannel.ForAddress(endpoint)); + } + + private static RPCClient CreateBitcoindRpc() + { + var url = Env("BITCOIND_RPC_URL", "http://localhost:18443"); + var cred = new NetworkCredential(Env("BITCOIND_RPC_USER", "polaruser"), Env("BITCOIND_RPC_PASS", "polarpass")); + var rpc = new RPCClient(cred, new Uri(url), Network.RegTest); + return rpc.SetWalletContext(Env("BITCOIND_RPC_WALLET", "default")); + } + + private static string Env(string name, string fallback) + => Environment.GetEnvironmentVariable(name) is { Length: > 0 } v ? v : fallback; +} diff --git a/test/NodeGuard.Tests/Jobs/MonitorRebalancesJobTests.cs b/test/NodeGuard.Tests/Jobs/MonitorRebalancesJobTests.cs index 4468941f..96f652ae 100644 --- a/test/NodeGuard.Tests/Jobs/MonitorRebalancesJobTests.cs +++ b/test/NodeGuard.Tests/Jobs/MonitorRebalancesJobTests.cs @@ -67,7 +67,7 @@ private static Rebalance MakeRebalance( RequestedAmountSats = 100_000, MaxFeePct = 0.05, // Default to "fresh" so tests don't accidentally trip the age-based stuck-row - // fallback in the Pending/Probing null branch. + // fallback in the Pending null branch. UpdateDatetime = updateDatetime ?? DateTimeOffset.UtcNow, }; @@ -157,33 +157,11 @@ public async Task Execute_InFlight_LndNotFound_RowMarkedFailed() _rebalanceRepo.Verify(r => r.Update(reb), Times.Once); } - [Fact] - public async Task Execute_Probing_LndNotFound_RowLeftAlone() - { - // ExecuteAsync persists PaymentHashHex right after AddInvoice but does not call - // SendPaymentV2 until the probe succeeds. While Status=Probing, "no record in LND" is - // the expected state — flipping to Failed here would kill an in-progress probe. - var reb = MakeRebalance(RebalanceStatus.Probing); - _rebalanceRepo.Setup(r => r.GetReconcilable(It.IsAny())).ReturnsAsync(new List { reb }); - _lightning.Setup(x => x.TrackPaymentV2Async(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((Payment?)null); - - await CreateJob().Execute(_ctx.Object); - - reb.Status.Should().Be(RebalanceStatus.Probing); - _rebalanceRepo.Verify(r => r.Update(It.IsAny()), Times.Never); - _audit.Verify(a => a.LogSystemAsync( - It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); - } - [Fact] public async Task Execute_Pending_LndNotFound_RowLeftAlone() { - // Same reasoning as Probing — the brief Pending+hash window inside ExecuteAsync - // between AddInvoice and the Status=Probing flip. SendPaymentV2 hasn't fired; LND - // can't possibly know about the hash yet. + // The brief Pending+hash window inside ExecuteAsync between AddInvoice and SendPaymentV2. + // SendPaymentV2 hasn't fired; LND can't possibly know about the hash yet, so leave it. var reb = MakeRebalance(RebalanceStatus.Pending); _rebalanceRepo.Setup(r => r.GetReconcilable(It.IsAny())).ReturnsAsync(new List { reb }); _lightning.Setup(x => x.TrackPaymentV2Async(It.IsAny(), It.IsAny(), It.IsAny())) @@ -221,28 +199,6 @@ public async Task Execute_Pending_LndNotFound_OlderThanWindow_FlipsToFailed() Times.Once); } - [Fact] - public async Task Execute_Probing_LndNotFound_OlderThanWindow_FlipsToFailed() - { - // Same age-based safety net as the Pending case — the null branch handles Pending and - // Probing identically, so a Probing row stuck past the reconciliation window must also - // flip to Failed. Pinned independently so splitting the two statuses can't regress this. - var stale = DateTimeOffset.UtcNow.AddHours(-(Constants.REBALANCE_RECONCILE_TERMINAL_WINDOW_HOURS + 1)); - var reb = MakeRebalance(RebalanceStatus.Probing, updateDatetime: stale); - _rebalanceRepo.Setup(r => r.GetReconcilable(It.IsAny())).ReturnsAsync(new List { reb }); - _lightning.Setup(x => x.TrackPaymentV2Async(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync((Payment?)null); - - await CreateJob().Execute(_ctx.Object); - - reb.Status.Should().Be(RebalanceStatus.Failed); - _rebalanceRepo.Verify(r => r.Update(reb), Times.Once); - _audit.Verify(a => a.LogSystemAsync( - AuditActionType.RebalanceCompleted, AuditEventType.Failure, - AuditObjectType.Rebalance, reb.Id.ToString(), It.IsAny()), - Times.Once); - } - [Fact] public async Task Execute_Pending_LndFailedPayment_RowLeftAlone() { @@ -296,26 +252,6 @@ public async Task Execute_Pending_LndSucceededPayment_RowLeftAlone() Times.Never); } - [Fact] - public async Task Execute_Probing_LndHasRecord_RowLeftAlone() - { - // While Status=Probing, the local ExecuteAsync owns the row — the monitor stays back - // regardless of what LND has on the hash. Covers the normal in-progress probe. - var reb = MakeRebalance(RebalanceStatus.Probing); - _rebalanceRepo.Setup(r => r.GetReconcilable(It.IsAny())).ReturnsAsync(new List { reb }); - _lightning.Setup(x => x.TrackPaymentV2Async(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.InFlight }); - - await CreateJob().Execute(_ctx.Object); - - reb.Status.Should().Be(RebalanceStatus.Probing); - _rebalanceRepo.Verify(r => r.Update(It.IsAny()), Times.Never); - _audit.Verify(a => a.LogSystemAsync( - It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny()), - Times.Never); - } - [Fact] public async Task Execute_RecentlyFailed_LndNotFound_RowLeftAlone() { diff --git a/test/NodeGuard.Tests/NodeGuard.Tests.csproj b/test/NodeGuard.Tests/NodeGuard.Tests.csproj index 8ce4b162..c36730f1 100644 --- a/test/NodeGuard.Tests/NodeGuard.Tests.csproj +++ b/test/NodeGuard.Tests/NodeGuard.Tests.csproj @@ -12,6 +12,7 @@ + diff --git a/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs b/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs index 70be76e3..3df4e2cd 100644 --- a/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs +++ b/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs @@ -1525,7 +1525,7 @@ public async Task RequestRebalance_Success_PassesOptionalsAndReturnsResponse() SourceChannelId = 11, TargetPubkey = "peer-pubkey", TimeoutSeconds = 90, - ProbeBackoffRatio = 0.5, + AmountBackoffRatio = 0.5, MaxAttempts = 4, }; @@ -1546,7 +1546,7 @@ public async Task RequestRebalance_Success_PassesOptionalsAndReturnsResponse() capturedRequest.SourceChannelId.Should().Be(11); capturedRequest.TargetPubkey.Should().Be("peer-pubkey"); capturedRequest.TimeoutSeconds.Should().Be(90); - capturedRequest.ProbeBackoffRatio.Should().Be(0.5); + capturedRequest.AmountBackoffRatio.Should().Be(0.5); capturedRequest.MaxAttempts.Should().Be(4); capturedRequest.IsManual.Should().BeTrue(); } @@ -1629,7 +1629,7 @@ public async Task GetRebalance_Found_ReturnsMappedResponse() FeePaidMsat = 3000, SourceChanIdLnd = 123456UL, TargetPubkey = "peer-pubkey", - ProbeBackoffRatio = 0.5, + AmountBackoffRatio = 0.5, MaxAttempts = 4, CreationDatetime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_000), UpdateDatetime = DateTimeOffset.FromUnixTimeSeconds(1_700_000_100), @@ -1656,7 +1656,7 @@ public async Task GetRebalance_Found_ReturnsMappedResponse() response.EffectivePpm.Should().Be(3000L * 1000L / 950L); response.SourceChanId.Should().Be(123456UL); response.TargetPubkey.Should().Be("peer-pubkey"); - response.ProbeBackoffRatio.Should().Be(0.5); + response.AmountBackoffRatio.Should().Be(0.5); response.MaxAttempts.Should().Be(4); response.CreationDatetimeUnix.Should().Be(1_700_000_000); response.UpdateDatetimeUnix.Should().Be(1_700_000_100); diff --git a/test/NodeGuard.Tests/Services/LightningServiceRebalanceTests.cs b/test/NodeGuard.Tests/Services/LightningServiceRebalanceTests.cs deleted file mode 100644 index b4d4a791..00000000 --- a/test/NodeGuard.Tests/Services/LightningServiceRebalanceTests.cs +++ /dev/null @@ -1,132 +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 Lnrpc; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; -using NodeGuard.Data; -using NodeGuard.Data.Models; -using NodeGuard.Data.Repositories.Interfaces; - -namespace NodeGuard.Services; - -/// -/// Tests for the rebalance-specific methods added to LightningService: -/// ProbeRouteAsync (BoS-style binary-search probing) and -/// GetLocalOutboundFeeRatePpmAsync (correct policy-side selection on a ChannelEdge). -/// -public class LightningServiceRebalanceTests -{ - private readonly Mock> _logger = new(); - private readonly Mock _lightningClient = new(); - private readonly Mock _lightningRouter = new(); - private readonly Mock> _dbContextFactory = new(); - - private LightningService CreateService() => new( - _logger.Object, - new Mock().Object, - new Mock().Object, - _dbContextFactory.Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - new Mock().Object, - _lightningClient.Object, - _lightningRouter.Object); - - private static Node CreateNode(string pubkey = "030000000000000000000000000000000000000000000000000000000000000001") - => new() { Id = 1, PubKey = pubkey, Endpoint = "localhost:10009", ChannelAdminMacaroon = "mac" }; - - private static Lnrpc.Route MakeRoute(long amtMsat = 100_000_000) - { - var route = new Lnrpc.Route { TotalAmtMsat = amtMsat, TotalFeesMsat = 0 }; - route.Hops.Add(new Hop { ChanId = 1, AmtToForwardMsat = amtMsat }); - return route; - } - - [Fact] - public async Task ProbeRouteAsync_QueryRoutesAlwaysEmpty_ReturnsNoRoute() - { - _lightningClient - .Setup(x => x.QueryRoutes(It.IsAny(), It.IsAny(), null)) - .ReturnsAsync(new QueryRoutesResponse()); - - var service = CreateService(); - var result = await service.ProbeRouteAsync(CreateNode(), 100_000, 1_000_000, - null, null, Constants.REBALANCE_PROBE_BACKOFF_RATIO, CancellationToken.None); - - result.Should().BeOfType(); - } - - [Fact] - public async Task ProbeRouteAsync_LastHopIncorrectPaymentDetails_ReturnsSuccessAtFullAmount() - { - var route = MakeRoute(); - var routesResponse = new QueryRoutesResponse(); - routesResponse.Routes.Add(route); - _lightningClient - .Setup(x => x.QueryRoutes(It.IsAny(), It.IsAny(), null)) - .ReturnsAsync(routesResponse); - - // The probe-success signal: random hash failed at the last hop, but routing worked. - _lightningRouter - .Setup(x => x.SendToRouteV2Async(It.IsAny(), It.IsAny(), - It.IsAny(), null)) - .ReturnsAsync(new HTLCAttempt - { - Failure = new Failure { Code = Failure.Types.FailureCode.IncorrectOrUnknownPaymentDetails }, - }); - - var service = CreateService(); - var result = await service.ProbeRouteAsync(CreateNode(), 100_000, 1_000_000, - null, null, Constants.REBALANCE_PROBE_BACKOFF_RATIO, CancellationToken.None); - - var success = result.Should().BeOfType().Which; - success.AmountSats.Should().Be(100_000); - success.Route.Should().BeSameAs(route); - } - - [Fact] - public async Task ProbeRouteAsync_AllRoutesFailMidPath_HalvesUntilBelowMinThenReturnsNoRoute() - { - var routesResponse = new QueryRoutesResponse(); - routesResponse.Routes.Add(MakeRoute()); - _lightningClient - .Setup(x => x.QueryRoutes(It.IsAny(), It.IsAny(), null)) - .ReturnsAsync(routesResponse); - - // Mid-route failure: not the IncorrectOrUnknownPaymentDetails signal. - _lightningRouter - .Setup(x => x.SendToRouteV2Async(It.IsAny(), It.IsAny(), - It.IsAny(), null)) - .ReturnsAsync(new HTLCAttempt - { - Failure = new Failure { Code = Failure.Types.FailureCode.TemporaryChannelFailure }, - }); - - var service = CreateService(); - var result = await service.ProbeRouteAsync(CreateNode(), 100_000, 1_000_000, - null, null, Constants.REBALANCE_PROBE_BACKOFF_RATIO, CancellationToken.None); - - result.Should().BeOfType(); - } - -} diff --git a/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs b/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs index 2a947537..7dca24fa 100644 --- a/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs +++ b/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs @@ -150,17 +150,20 @@ await FluentActions.Awaiting(() => service.RebalanceAsync(request)) .WithMessage("*already at or above the requested inbound ratio*"); } - [Fact] - public async Task RebalanceAsync_ProbeBackoffRatioOutOfRange_Throws() + [Theory] + [InlineData(1.5)] // above the closed upper bound + [InlineData(0.0)] // zero would zero the next amount + [InlineData(-0.1)] // negative + public async Task RebalanceAsync_AmountBackoffRatioOutOfRange_Throws(double ratio) { - // Ratio must be in (0, 1) exclusive. 1.0 never shrinks; 0 zeroes the next try. + // Ratio must be in the half-open interval (0, 1]. 1.0 is now allowed (never shrink). var service = CreateService(); var request = new RebalanceRequest(NodeId: 1, ValidChannelId, ValidTargetPubkey, - AmountSats: 1000, MaxFeePct: null, ProbeBackoffRatio: 1.0); + AmountSats: 1000, MaxFeePct: null, AmountBackoffRatio: ratio); await FluentActions.Awaiting(() => service.RebalanceAsync(request)) .Should().ThrowAsync() - .WithMessage("*Probe backoff ratio must be in the open interval (0, 1)*"); + .WithMessage("*Amount backoff ratio must be in the half-open interval (0, 1]*"); } [Fact] @@ -301,9 +304,9 @@ public async Task RebalanceAsync_UserSuppliedFeePct_PersistedAsIs() // Probe returns NoRoute so we short-circuit before payment. _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); - _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProbeResult.NoRoute("test")); + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Failed, FailureReason = PaymentFailureReason.FailureReasonNoRoute }); var service = CreateService(); var request = new RebalanceRequest(NodeId: node.Id, ValidChannelId, ValidTargetPubkey, @@ -316,53 +319,45 @@ public async Task RebalanceAsync_UserSuppliedFeePct_PersistedAsIs() [Fact] - public async Task RebalanceAsync_UserSuppliedProbeBackoffRatio_PersistedAndPassedToProbe() + public async Task RebalanceAsync_UserSuppliedBackoffRatio_Persisted() { var node = CreateNode(); _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); StubRepoForCapture(); _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); - - double capturedRatio = 0; - _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((_, _, _, _, _, ratio, _) => capturedRatio = ratio) - .ReturnsAsync(new ProbeResult.NoRoute("test")); + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Failed, FailureReason = PaymentFailureReason.FailureReasonNoRoute }); var service = CreateService(); var request = new RebalanceRequest(NodeId: node.Id, ValidChannelId, ValidTargetPubkey, - AmountSats: 100_000, MaxFeePct: 0.025, ProbeBackoffRatio: 0.8); + AmountSats: 100_000, MaxFeePct: 0.025, AmountBackoffRatio: 0.8); var result = await service.RebalanceAsync(request); - result.ProbeBackoffRatio.Should().Be(0.8); - capturedRatio.Should().Be(0.8); + result.AmountBackoffRatio.Should().Be(0.8); } [Fact] - public async Task RebalanceAsync_NullProbeBackoffRatio_FallsBackToConstant() + public async Task RebalanceAsync_NullBackoffRatio_PersistedAsNull() { var node = CreateNode(); _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); StubRepoForCapture(); _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); - - double capturedRatio = 0; - _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((_, _, _, _, _, ratio, _) => capturedRatio = ratio) - .ReturnsAsync(new ProbeResult.NoRoute("test")); + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Failed, FailureReason = PaymentFailureReason.FailureReasonNoRoute }); var service = CreateService(); var request = new RebalanceRequest(NodeId: node.Id, ValidChannelId, ValidTargetPubkey, - AmountSats: 100_000, MaxFeePct: 0.025, ProbeBackoffRatio: null); + AmountSats: 100_000, MaxFeePct: 0.025, AmountBackoffRatio: null); var result = await service.RebalanceAsync(request); - result.ProbeBackoffRatio.Should().BeNull(); - capturedRatio.Should().Be(Constants.REBALANCE_PROBE_BACKOFF_RATIO); + result.AmountBackoffRatio.Should().BeNull(); } [Fact] @@ -379,10 +374,7 @@ public async Task RebalanceAsync_NoUserFeePct_FallsBackToDefaultFeePct() .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); // Make the rebalance succeed so retry-escalation doesn't bump ppm before assertion. - _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProbeResult.Success(100_000, new Lnrpc.Route())); - _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Succeeded, FeeMsat = 1000 }); @@ -401,7 +393,7 @@ public async Task RebalanceAsync_NoUserFeePct_FallsBackToDefaultFeePct() } [Fact] - public async Task ExecuteAsync_ProbeSucceeds_PaymentSucceeds_StatusSucceeded() + public async Task ExecuteAsync_PaymentSucceeds_StatusSucceeded() { var node = CreateNode(); _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); @@ -410,13 +402,7 @@ public async Task ExecuteAsync_ProbeSucceeds_PaymentSucceeds_StatusSucceeded() _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); - var route = new Lnrpc.Route(); - route.Hops.Add(new Hop { ChanId = 1, AmtToForwardMsat = 100_000_000 }); - _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProbeResult.Success(100_000, route)); - - _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new Payment { @@ -449,9 +435,9 @@ public async Task ExecuteAsync_InvoiceExpiryCoversFullRetryWindowPlusBuffer() _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) .Callback((_, _, _, expiry) => capturedExpiry = expiry) .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); - _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProbeResult.NoRoute("stop")); + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Failed, FailureReason = PaymentFailureReason.FailureReasonNoRoute }); var service = CreateService(); // TimeoutSeconds=60, MaxAttempts=3 → backoff = 60 + 120 = 180 → 60 + 180 + buffer. @@ -479,9 +465,9 @@ public async Task ExecuteAsync_InvoiceExpiry_MaxAttempts1_OmitsBackoffSum() _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) .Callback((_, _, _, expiry) => capturedExpiry = expiry) .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); - _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProbeResult.NoRoute("stop")); + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Failed, FailureReason = PaymentFailureReason.FailureReasonNoRoute }); var service = CreateService(); var request = new RebalanceRequest(node.Id, ValidChannelId, ValidTargetPubkey, 100_000, MaxFeePct: 0.05, @@ -510,26 +496,26 @@ public async Task ExecuteAsync_PersistsPaymentHashHexBeforePayment() RHash = Google.Protobuf.ByteString.CopyFrom(hashBytes), }); - string? hashAtProbeTime = null; - _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Callback((_, _, _, _, _, _, _) => + string? hashAtDispatch = null; + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, _, _, _, _, _, _) => { - // By the time the probe is invoked, the hash must already be on the row. + // By the time SendPaymentV2 is dispatched, the hash must already be on the row. var captured = _rebalanceRepo.Invocations .Where(i => i.Method.Name == nameof(IRebalanceRepository.Update)) .Select(i => (Rebalance)i.Arguments[0]) .LastOrDefault(); - hashAtProbeTime = captured?.PaymentHashHex; + hashAtDispatch = captured?.PaymentHashHex; }) - .ReturnsAsync(new ProbeResult.NoRoute("test")); + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Failed, FailureReason = PaymentFailureReason.FailureReasonNoRoute }); var service = CreateService(); var request = new RebalanceRequest(node.Id, ValidChannelId, ValidTargetPubkey, 100_000, MaxFeePct: 0.05); var result = await service.RebalanceAsync(request); result.PaymentHashHex.Should().Be("abcdef01"); - hashAtProbeTime.Should().Be("abcdef01"); + hashAtDispatch.Should().Be("abcdef01"); } [Fact] @@ -540,9 +526,9 @@ public async Task ExecuteAsync_ProbeNoRoute_StatusNoRoute_RetryScheduled() StubRepoForCapture(); _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); - _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProbeResult.NoRoute("exhausted")); + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Failed, FailureReason = PaymentFailureReason.FailureReasonNoRoute }); var service = CreateService(); var request = new RebalanceRequest(node.Id, ValidChannelId, ValidTargetPubkey, 100_000, MaxFeePct: 0.05); @@ -563,10 +549,7 @@ public async Task ExecuteAsync_PaymentInsufficientBalance_NoRetryScheduled() StubRepoForCapture(); _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); - _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProbeResult.Success(100_000, new Lnrpc.Route())); - _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new Payment { @@ -593,9 +576,9 @@ public async Task ExecuteAsync_ScheduleRetry_EscalatesFeePctFromInitialToRetry() StubRepoForCapture(); _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); - _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProbeResult.NoRoute()); + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Failed, FailureReason = PaymentFailureReason.FailureReasonNoRoute }); var service = CreateService(); var request = new RebalanceRequest(node.Id, ValidChannelId, TargetPubkey: ValidTargetPubkey, @@ -617,9 +600,9 @@ public async Task ExecuteAsync_PerRowRetryMaxFeePct_OverridesConstantOnEscalatio StubRepoForCapture(); _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); - _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProbeResult.NoRoute()); + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Failed, FailureReason = PaymentFailureReason.FailureReasonNoRoute }); var service = CreateService(); // Per-row retry pct = 0.09%, overriding the constant default @@ -642,9 +625,9 @@ public async Task ExecuteAsync_PerRowMaxAttempts1_NoRetryScheduled() StubRepoForCapture(); _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); - _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProbeResult.NoRoute()); + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Failed, FailureReason = PaymentFailureReason.FailureReasonNoRoute }); var service = CreateService(); // MaxAttempts=1 → first attempt is also the last; no Quartz retry should be scheduled. @@ -682,9 +665,9 @@ public async Task ExecuteAsync_AtMaxAttempts_NoFurtherRetryScheduled() _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); - _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProbeResult.NoRoute()); + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Failed, FailureReason = PaymentFailureReason.FailureReasonNoRoute }); var service = CreateService(); var result = await service.ExecuteAsync(existing.Id); @@ -703,9 +686,9 @@ public async Task RebalanceAsync_AuditsInitiation() StubRepoForCapture(); _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); - _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProbeResult.NoRoute()); + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Failed, FailureReason = PaymentFailureReason.FailureReasonNoRoute }); var service = CreateService(); await service.RebalanceAsync(new RebalanceRequest(node.Id, ValidChannelId, ValidTargetPubkey, AmountSats: 100_000, MaxFeePct: 0.1, TimeoutSeconds: 500)); @@ -765,7 +748,7 @@ public async Task ExecuteAsync_RetryPreflight_PriorSucceeded_AdoptsAndSkipsRetry result.PreimageHex.Should().Be("abc"); _lightning.Verify(x => x.AddInvoiceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - _lightning.Verify(x => x.SendPaymentV2Async(It.IsAny(), It.IsAny(), It.IsAny(), + _lightning.Verify(x => x.SendPaymentV2Async(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); _audit.Verify(a => a.LogAsync( @@ -779,7 +762,7 @@ public async Task ExecuteAsync_RetryPreflight_PriorFailed_ProceedsWithNewInvoice { // The common retry case: LND confirms the prior attempt failed, matching the local // row's pre-retry state. Pre-flight must NOT bail — it must fall through to the normal - // AddInvoice → probe → pay flow so the retry actually runs. + // AddInvoice → pay flow so the retry actually runs. var node = CreateNode(); var rebalance = BuildRetryRow(node); _rebalanceRepo.Setup(r => r.GetById(rebalance.Id)).ReturnsAsync(rebalance); @@ -797,9 +780,9 @@ public async Task ExecuteAsync_RetryPreflight_PriorFailed_ProceedsWithNewInvoice PaymentRequest = "lnbc...", RHash = Google.Protobuf.ByteString.CopyFrom(new byte[] { 0xAA, 0xBB }), }); - _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProbeResult.NoRoute("stop")); + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Failed, FailureReason = PaymentFailureReason.FailureReasonNoRoute }); var service = CreateService(); var result = await service.ExecuteAsync(rebalance.Id); @@ -824,9 +807,9 @@ public async Task ExecuteAsync_RetryPreflight_LndNotFound_ProceedsWithNewInvoice .ReturnsAsync((Payment?)null); _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); - _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProbeResult.NoRoute("stop")); + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Failed, FailureReason = PaymentFailureReason.FailureReasonNoRoute }); var service = CreateService(); await service.ExecuteAsync(rebalance.Id); @@ -859,7 +842,7 @@ public async Task ExecuteAsync_RetryPreflight_PriorStillInFlight_SchedulesRetryW // No new invoice or payment — the live in-flight payment is left untouched. _lightning.Verify(x => x.AddInvoiceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); - _lightning.Verify(x => x.SendPaymentV2Async(It.IsAny(), It.IsAny(), It.IsAny(), + _lightning.Verify(x => x.SendPaymentV2Async(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); // The prior attempt's hash is preserved, not overwritten by a fresh invoice. @@ -883,9 +866,9 @@ public async Task ExecuteAsync_FirstAttempt_NoPriorHash_SkipsPreflightTrack() StubRepoForCapture(); _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); - _lightning.Setup(x => x.ProbeRouteAsync(node, It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(new ProbeResult.NoRoute("test")); + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Failed, FailureReason = PaymentFailureReason.FailureReasonNoRoute }); var service = CreateService(); await service.RebalanceAsync(new RebalanceRequest(node.Id, ValidChannelId, ValidTargetPubkey, 100_000, MaxFeePct: 0.05)); @@ -893,4 +876,130 @@ public async Task ExecuteAsync_FirstAttempt_NoPriorHash_SkipsPreflightTrack() _lightning.Verify(x => x.TrackPaymentV2Async(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } + + [Fact] + public async Task ExecuteAsync_CreatesAmountlessInvoice() + { + // The self-invoice must be amountless (Value 0) so the per-attempt amount can ride on + // SendPaymentV2 and the same invoice can settle a shrunk retry. + var node = CreateNode(); + _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); + StubRepoForCapture(); + + long capturedInvoiceAmount = -1; + _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, amount, _, _) => capturedInvoiceAmount = amount) + .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Failed, FailureReason = PaymentFailureReason.FailureReasonNoRoute }); + + var service = CreateService(); + await service.RebalanceAsync(new RebalanceRequest(node.Id, ValidChannelId, ValidTargetPubkey, 100_000, MaxFeePct: 0.05)); + + capturedInvoiceAmount.Should().Be(0); + } + + [Fact] + public async Task ExecuteAsync_SendsFullRequestedAmountOnFirstAttempt() + { + // Attempt 1 always pays the full requested amount (ratio^0 = 1), regardless of the ratio. + var node = CreateNode(); + _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); + StubRepoForCapture(); + _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new AddInvoiceResponse { PaymentRequest = "lnbc..." }); + + long capturedAmount = -1; + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, amount, _, _, _, _, _) => capturedAmount = amount) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Succeeded, FeeMsat = 1000 }); + + var service = CreateService(); + await service.RebalanceAsync(new RebalanceRequest(node.Id, ValidChannelId, ValidTargetPubkey, + 100_000, MaxFeePct: 0.05, AmountBackoffRatio: 0.8)); + + capturedAmount.Should().Be(100_000); + } + + [Fact] + public async Task ExecuteAsync_ShrinksAmountOnRetryByBackoffRatio() + { + // On a retry (attempt 2) with ratio 0.8, the amount paid is RequestedAmountSats × 0.8. + var node = CreateNode(); + var rebalance = BuildRetryRow(node); + rebalance.PaymentRequest = "lnbc-prior"; + rebalance.AmountBackoffRatio = 0.8; + _rebalanceRepo.Setup(r => r.GetById(rebalance.Id)).ReturnsAsync(rebalance); + _rebalanceRepo.Setup(r => r.Update(It.IsAny())).Returns((true, (string?)null)); + // Prior hash known to LND as Failed → pre-flight falls through to a real retry. + _lightning.Setup(x => x.TrackPaymentV2Async(node, It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Failed, FailureReason = PaymentFailureReason.FailureReasonNoRoute }); + + long capturedAmount = -1; + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, amount, _, _, _, _, _) => capturedAmount = amount) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Succeeded, FeeMsat = 1000 }); + + var service = CreateService(); + await service.ExecuteAsync(rebalance.Id); + + capturedAmount.Should().Be(80_000); // 100_000 × 0.8^(2-1) + } + + [Fact] + public async Task ExecuteAsync_RatioOne_DoesNotShrinkOnRetry() + { + // ratio = 1 ⇒ every attempt retries the full amount, even on a retry. + var node = CreateNode(); + var rebalance = BuildRetryRow(node); + rebalance.PaymentRequest = "lnbc-prior"; + rebalance.AmountBackoffRatio = 1.0; + _rebalanceRepo.Setup(r => r.GetById(rebalance.Id)).ReturnsAsync(rebalance); + _rebalanceRepo.Setup(r => r.Update(It.IsAny())).Returns((true, (string?)null)); + _lightning.Setup(x => x.TrackPaymentV2Async(node, It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Failed, FailureReason = PaymentFailureReason.FailureReasonNoRoute }); + + long capturedAmount = -1; + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, amount, _, _, _, _, _) => capturedAmount = amount) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Succeeded, FeeMsat = 1000 }); + + var service = CreateService(); + await service.ExecuteAsync(rebalance.Id); + + capturedAmount.Should().Be(100_000); + } + + [Fact] + public async Task ExecuteAsync_RetryReusesExistingInvoice_DoesNotCreateNew() + { + // A retry row already carries the amountless invoice from the first attempt. ExecuteAsync + // must reuse it (one invoice per rebalance), not mint a fresh one. + var node = CreateNode(); + var rebalance = BuildRetryRow(node); + rebalance.PaymentRequest = "lnbc-original-invoice"; + _rebalanceRepo.Setup(r => r.GetById(rebalance.Id)).ReturnsAsync(rebalance); + _rebalanceRepo.Setup(r => r.Update(It.IsAny())).Returns((true, (string?)null)); + _lightning.Setup(x => x.TrackPaymentV2Async(node, It.IsAny(), It.IsAny())) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Failed, FailureReason = PaymentFailureReason.FailureReasonNoRoute }); + + string? paidRequest = null; + _lightning.Setup(x => x.SendPaymentV2Async(node, It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, pr, _, _, _, _, _, _) => paidRequest = pr) + .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Failed, FailureReason = PaymentFailureReason.FailureReasonNoRoute }); + + var service = CreateService(); + var result = await service.ExecuteAsync(rebalance.Id); + + _lightning.Verify(x => x.AddInvoiceAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + paidRequest.Should().Be("lnbc-original-invoice"); + // The stable prior hash is preserved (single invoice across attempts). + result.PaymentHashHex.Should().Be("deadbeef"); + } }