diff --git a/src/Proto/nodeguard.proto b/src/Proto/nodeguard.proto index fdce92ee..59934937 100644 --- a/src/Proto/nodeguard.proto +++ b/src/Proto/nodeguard.proto @@ -512,10 +512,10 @@ message RequestRebalanceRequest { optional double max_fee_pct = 3; // Optional max fee cap for retry attempts, expressed as a percentage of the rebalanced amount (not a fraction of the outbound rate). e.g. 0.075 means 0.075% = 750 ppm. If unset, falls back to REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT optional double retry_max_fee_pct = 4; - // Optional NG channel id to use as the outbound channel + // NG channel id to use as the outbound channel. Required. optional int32 source_channel_id = 5; - // Optional explicit last-hop peer pubkey (33-byte hex). If unset, LND picks any - // inbound channel that satisfies the cost cap. + // Last-hop peer pubkey (33-byte hex) that constrains the receiving peer of the + // circular payment. Required. optional string target_pubkey = 6; // Pathfinding/payment timeout in seconds; 0 → server default (60s) int32 timeout_seconds = 7; diff --git a/src/Rpc/NodeGuardService.cs b/src/Rpc/NodeGuardService.cs index 7046aad1..7860291f 100644 --- a/src/Rpc/NodeGuardService.cs +++ b/src/Rpc/NodeGuardService.cs @@ -1212,14 +1212,20 @@ public override async Task RequestRebalance(RequestReb if (request.AmountSats <= 0) throw new RpcException(new Status(StatusCode.InvalidArgument, "amount_sats must be > 0")); + if (!request.HasSourceChannelId || request.SourceChannelId <= 0) + throw new RpcException(new Status(StatusCode.InvalidArgument, "source_channel_id is required")); + + if (!request.HasTargetPubkey || string.IsNullOrWhiteSpace(request.TargetPubkey)) + throw new RpcException(new Status(StatusCode.InvalidArgument, "target_pubkey is required")); + var node = await _nodeRepository.GetByPubkey(request.NodePubkey); if (node == null) throw new RpcException(new Status(StatusCode.NotFound, $"Node with pubkey {request.NodePubkey} not found")); var domainRequest = new RebalanceRequest( NodeId: node.Id, - SourceChannelId: request.HasSourceChannelId ? request.SourceChannelId : null, - TargetPubkey: request.HasTargetPubkey ? request.TargetPubkey : null, + SourceChannelId: request.SourceChannelId, + TargetPubkey: request.TargetPubkey, AmountSats: request.AmountSats, MaxFeePct: request.HasMaxFeePct ? request.MaxFeePct : null, TimeoutSeconds: request.TimeoutSeconds, diff --git a/src/Services/RebalanceService.cs b/src/Services/RebalanceService.cs index 2da87f1e..aec04e02 100644 --- a/src/Services/RebalanceService.cs +++ b/src/Services/RebalanceService.cs @@ -28,8 +28,8 @@ namespace NodeGuard.Services; public record RebalanceRequest( int NodeId, - int? SourceChannelId, - string? TargetPubkey, + int SourceChannelId, + string TargetPubkey, long AmountSats, double? MaxFeePct, int TimeoutSeconds = 60, @@ -104,18 +104,40 @@ public async Task RebalanceAsync(RebalanceRequest request, Cancellati "Retry max fee % must be greater than 0.", nameof(request.RetryMaxFeePct)); + if (request.SourceChannelId <= 0) + throw new ArgumentException( + "Source channel id is required.", + nameof(request.SourceChannelId)); + + if (string.IsNullOrWhiteSpace(request.TargetPubkey)) + throw new ArgumentException( + "Target pubkey is required.", + nameof(request.TargetPubkey)); + var node = await _nodeRepository.GetById(request.NodeId); if (node == null) throw new ArgumentException($"Node {request.NodeId} not found", nameof(request.NodeId)); - ulong? sourceChanIdLnd = null; - if (request.SourceChannelId.HasValue) - { - var sourceChannel = await _channelRepository.GetById(request.SourceChannelId.Value); - if (sourceChannel == null) - throw new ArgumentException($"Source channel {request.SourceChannelId} not found"); - sourceChanIdLnd = sourceChannel.ChanId; - } + var sourceChannel = await _channelRepository.GetById(request.SourceChannelId); + if (sourceChannel == null) + throw new ArgumentException( + $"Source channel {request.SourceChannelId} not found", + nameof(request.SourceChannelId)); + var sourceChanIdLnd = sourceChannel.ChanId; + + var counterpartyPeerId = sourceChannel.SourceNodeId == node.Id + ? sourceChannel.DestinationNodeId + : sourceChannel.SourceNodeId; + + var counterpartyPeer = await _nodeRepository.GetById(counterpartyPeerId); + if (counterpartyPeer == null) + throw new InvalidOperationException( + $"Counterparty peer node {counterpartyPeerId} not found for source channel {request.SourceChannelId}"); + + if (counterpartyPeer.PubKey == request.TargetPubkey) + throw new ArgumentException( + "Target pubkey is the same as the source channel's counterparty peer; rebalance would be a no-op.", + nameof(request.TargetPubkey)); // LastHopPubkey constrains the receiving peer, not a specific channel — LND picks // which of that peer's channels to use. We don't accept or persist a target chan_id. diff --git a/src/Shared/NewRebalanceModal.razor b/src/Shared/NewRebalanceModal.razor index fcdb80d3..bdc1d549 100644 --- a/src/Shared/NewRebalanceModal.razor +++ b/src/Shared/NewRebalanceModal.razor @@ -58,26 +58,28 @@ - Receiving peer (optional) - + Receiving peer + + DefaultItemText="Choose a receiving peer"> - @if (string.IsNullOrEmpty(_selectedTargetPubkey)) + @if (!string.IsNullOrEmpty(SourceChannelCounterpartyPubkey)) { - No receiving peer pinned — LND will pick any inbound peer that satisfies the cost cap. +
+ Source channel's counterparty (@SourceChannelCounterpartyPubkey.Substring(0, 10)…) is excluded — pinning the last hop to that peer would be a no-op. +
} - else if (_targetOutboundPpm.HasValue) + @if (!string.IsNullOrEmpty(_selectedTargetPubkey) && _targetOutboundPpm.HasValue) { Outbound fee rate to this peer: @_targetOutboundPpm.Value ppm (for reference only). } @@ -110,7 +112,8 @@ @if (!string.IsNullOrEmpty(_selectedTargetPubkey)) {
- Receiving peer pinned: @_selectedTargetPubkey.Substring(0, 10)…. The exact channel that lands the sats is LND's call — no per-channel projection possible. + Receiving peer pinned: @_selectedTargetPubkey.Substring(0, 10)…. The exact + channel that lands the sats is LND's call — no per-channel projection possible.
} @@ -301,6 +304,21 @@ (c.SourceNode?.PubKey == _selectedNode?.PubKey ? c.DestinationNode?.PubKey : c.SourceNode?.PubKey) == _selectedSourcePubkey ).ToList(); + // Pubkey of the peer on the OTHER side of the selected source channel. Pinning the + // last-hop to this same peer would route the sats straight back to where they came from + // (no-op rebalance). The service rejects this combination; the modal mirrors the guard. + private string? SourceChannelCounterpartyPubkey => + _selectedSourceChannel == null || _selectedNode == null + ? null + : _selectedSourceChannel.SourceNodeId == _selectedNode.Id + ? _selectedSourceChannel.DestinationNode?.PubKey + : _selectedSourceChannel.SourceNode?.PubKey; + + private List AvailableReceivingPeers => + string.IsNullOrEmpty(SourceChannelCounterpartyPubkey) + ? _peerOptions + : _peerOptions.Where(p => p.Pubkey != SourceChannelCounterpartyPubkey).ToList(); + private decimal CurrentInboundPct => _sourceLocalSats.HasValue && _sourceRemoteSats.HasValue && (_sourceLocalSats.Value + _sourceRemoteSats.Value) > 0 @@ -325,6 +343,8 @@ private bool CanSubmit => _selectedNode != null && _selectedSourceChannel != null + && !string.IsNullOrEmpty(_selectedTargetPubkey) + && _selectedTargetPubkey != SourceChannelCounterpartyPubkey && _sourceLocalSats.HasValue && ComputeAmountSats() > 0 && _maxFeePct > 0m @@ -474,6 +494,14 @@ _selectedSourceChannel = _nodeChannels.FirstOrDefault(c => c.Id == channelId); _sourceLocalSats = null; _sourceRemoteSats = null; + // If the user had already picked a receiving peer and the newly-chosen source channel + // makes that peer invalid (it's the channel's counterparty), drop the selection so + // CanSubmit doesn't lie and the user re-picks against the filtered list. + if (!string.IsNullOrEmpty(_selectedTargetPubkey) && _selectedTargetPubkey == SourceChannelCounterpartyPubkey) + { + _selectedTargetPubkey = null; + _targetOutboundPpm = null; + } if (_selectedSourceChannel != null && _selectedNode != null) { var (local, remote) = await TryGetLiveBalance(_selectedNode, _selectedSourceChannel.ChanId); @@ -556,7 +584,7 @@ var request = new RebalanceRequest( NodeId: _selectedNode!.Id, SourceChannelId: _selectedSourceChannel!.Id, - TargetPubkey: _selectedTargetPubkey, + TargetPubkey: _selectedTargetPubkey!, AmountSats: amountSats, MaxFeePct: _maxFeePct > 0m ? (double)_maxFeePct : (double?)null, TimeoutSeconds: _timeoutSeconds, diff --git a/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs b/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs index 1a19f574..70be76e3 100644 --- a/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs +++ b/test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs @@ -1435,7 +1435,15 @@ public async Task RequestRebalance_NodeNotFound_ThrowsNotFound() nodeRepoMock.Setup(r => r.GetByPubkey("pubkey1")).ReturnsAsync((Node?)null); var service = CreateNodeGuardService(nodeRepository: nodeRepoMock.Object); - var request = new RequestRebalanceRequest { NodePubkey = "pubkey1", AmountSats = 1000 }; + // source_channel_id and target_pubkey are required guards that fire BEFORE the node + // lookup; supply both so this test actually exercises the node-not-found path. + var request = new RequestRebalanceRequest + { + NodePubkey = "pubkey1", + AmountSats = 1000, + SourceChannelId = 1, + TargetPubkey = "peer-pubkey", + }; var act = () => service.RequestRebalance(request, TestServerCallContext.Create()); @@ -1443,6 +1451,42 @@ public async Task RequestRebalance_NodeNotFound_ThrowsNotFound() .Which.StatusCode.Should().Be(StatusCode.NotFound); } + [Fact] + public async Task RequestRebalance_MissingSourceChannelId_ThrowsInvalidArgument() + { + var service = CreateNodeGuardService(); + var request = new RequestRebalanceRequest + { + NodePubkey = "pubkey1", + AmountSats = 1000, + TargetPubkey = "peer-pubkey", + // SourceChannelId intentionally unset. + }; + + var act = () => service.RequestRebalance(request, TestServerCallContext.Create()); + + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(StatusCode.InvalidArgument); + } + + [Fact] + public async Task RequestRebalance_MissingTargetPubkey_ThrowsInvalidArgument() + { + var service = CreateNodeGuardService(); + var request = new RequestRebalanceRequest + { + NodePubkey = "pubkey1", + AmountSats = 1000, + SourceChannelId = 1, + // TargetPubkey intentionally unset. + }; + + var act = () => service.RequestRebalance(request, TestServerCallContext.Create()); + + (await act.Should().ThrowAsync()) + .Which.StatusCode.Should().Be(StatusCode.InvalidArgument); + } + [Fact] public async Task RequestRebalance_Success_PassesOptionalsAndReturnsResponse() { @@ -1523,7 +1567,15 @@ public async Task RequestRebalance_DomainArgumentException_ThrowsInvalidArgument nodeRepository: nodeRepoMock.Object, rebalanceService: rebalanceServiceMock.Object); - var request = new RequestRebalanceRequest { NodePubkey = "pubkey1", AmountSats = 1000 }; + // Required gRPC guards must pass so the request actually reaches the service and we + // can verify the domain ArgumentException → StatusCode.InvalidArgument mapping. + var request = new RequestRebalanceRequest + { + NodePubkey = "pubkey1", + AmountSats = 1000, + SourceChannelId = 1, + TargetPubkey = "peer-pubkey", + }; var act = () => service.RequestRebalance(request, TestServerCallContext.Create()); diff --git a/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs b/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs index 142dd84f..2a947537 100644 --- a/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs +++ b/test/NodeGuard.Tests/Services/RebalanceServiceTests.cs @@ -44,6 +44,13 @@ public RebalanceServiceTests() { _schedulerFactory.Setup(x => x.GetScheduler(It.IsAny())) .ReturnsAsync(_scheduler.Object); + // SourceChannelId is now mandatory on RebalanceRequest, so any test that reaches the + // source-channel lookup needs a Channel back. The service also resolves the channel's + // counterparty peer via _nodeRepository.GetById to enforce the "TargetPubkey != counterparty" + // guard, so the counterparty node has to resolve too. Pre-stub both so individual tests + // don't repeat it. Tests that explicitly want either lookup to fail can override. + StubChannelRepo(); + StubCounterpartyNode(); } private RebalanceService CreateService() => new( @@ -65,6 +72,54 @@ private static Node CreateNode(int id = 1, string pubkey = "03000000000000000000 ChannelAdminMacaroon = "mac", }; + // SourceChannelId and TargetPubkey are required on the domain request; tests that don't care + // about either still need to supply something that satisfies the validators (and, for flow + // tests, something the channel repo can resolve via StubChannelRepo). + private const int ValidChannelId = 1; + private const string ValidTargetPubkey = "030000000000000000000000000000000000000000000000000000000000000099"; + // Local node id (matches CreateNode() default) is 1; the source channel's counterparty is + // node id 2 with this pubkey — different from ValidTargetPubkey so the no-op guard passes + // by default. Tests that exercise the guard pass CounterpartyPubkey as the request's + // TargetPubkey to make it collide with the (default) counterparty pubkey. + private const int CounterpartyNodeId = 2; + private const string CounterpartyPubkey = "030000000000000000000000000000000000000000000000000000000000000002"; + + /// + /// Stubs ChannelRepository.GetById so the source-channel lookup inside RebalanceAsync resolves + /// to a real-looking Channel instead of throwing. The local node (id 1 via CreateNode()) is + /// set as the SourceNode and CounterpartyNodeId as the DestinationNode, mirroring the + /// "we opened this channel" case. + /// + private void StubChannelRepo(int channelId = ValidChannelId, ulong chanIdLnd = 12345UL) + { + _channelRepo.Setup(x => x.GetById(channelId)).ReturnsAsync(new Channel + { + Id = channelId, + ChanId = chanIdLnd, + SatsAmount = 1_000_000, + Status = Channel.ChannelStatus.Open, + FundingTx = "tx", + SourceNodeId = 1, + DestinationNodeId = CounterpartyNodeId, + }); + } + + /// + /// Stubs NodeRepository.GetById for the source channel's counterparty peer. The service + /// looks this up to evaluate the "TargetPubkey != counterparty" guard. + /// + private void StubCounterpartyNode(string pubkey = CounterpartyPubkey) + { + _nodeRepo.Setup(x => x.GetById(CounterpartyNodeId, It.IsAny())).ReturnsAsync(new Node + { + Id = CounterpartyNodeId, + Name = "counterparty", + PubKey = pubkey, + Endpoint = "peer:10009", + ChannelAdminMacaroon = "mac", + }); + } + /// /// Wires up the repository mocks so that AddAsync stamps an Id and GetById returns the /// same instance — mimicking what EF would do without an actual database. @@ -88,7 +143,7 @@ public async Task RebalanceAsync_AmountZero_ThrowsWithTargetEqualsCurrentMessage // Amount=0 is what the modal computes when target inbound % equals (or is below) current inbound %. // The service must reject this with a clear, user-facing message. var service = CreateService(); - var request = new RebalanceRequest(NodeId: 1, null, null, AmountSats: 0, MaxFeePct: null); + var request = new RebalanceRequest(NodeId: 1, ValidChannelId, ValidTargetPubkey, AmountSats: 0, MaxFeePct: null); await FluentActions.Awaiting(() => service.RebalanceAsync(request)) .Should().ThrowAsync() @@ -100,7 +155,7 @@ public async Task RebalanceAsync_ProbeBackoffRatioOutOfRange_Throws() { // Ratio must be in (0, 1) exclusive. 1.0 never shrinks; 0 zeroes the next try. var service = CreateService(); - var request = new RebalanceRequest(NodeId: 1, null, null, + var request = new RebalanceRequest(NodeId: 1, ValidChannelId, ValidTargetPubkey, AmountSats: 1000, MaxFeePct: null, ProbeBackoffRatio: 1.0); await FluentActions.Awaiting(() => service.RebalanceAsync(request)) @@ -112,7 +167,7 @@ await FluentActions.Awaiting(() => service.RebalanceAsync(request)) public async Task RebalanceAsync_MaxAttemptsZeroOrNegative_Throws() { var service = CreateService(); - var request = new RebalanceRequest(NodeId: 1, null, null, + var request = new RebalanceRequest(NodeId: 1, ValidChannelId, ValidTargetPubkey, AmountSats: 1000, MaxFeePct: null, MaxAttempts: 0); await FluentActions.Awaiting(() => service.RebalanceAsync(request)) @@ -124,7 +179,7 @@ await FluentActions.Awaiting(() => service.RebalanceAsync(request)) public async Task RebalanceAsync_RetryMaxFeePctOutOfRange_Throws() { var service = CreateService(); - var request = new RebalanceRequest(NodeId: 1, null, null, + var request = new RebalanceRequest(NodeId: 1, ValidChannelId, ValidTargetPubkey, AmountSats: 1000, MaxFeePct: null, RetryMaxFeePct: -0.1); await FluentActions.Awaiting(() => service.RebalanceAsync(request)) @@ -137,12 +192,105 @@ public async Task RebalanceAsync_NodeNotFound_Throws() { _nodeRepo.Setup(x => x.GetById(99, It.IsAny())).ReturnsAsync((Node?)null); var service = CreateService(); - var request = new RebalanceRequest(NodeId: 99, null, null, AmountSats: 1000, MaxFeePct: null); + var request = new RebalanceRequest(NodeId: 99, ValidChannelId, ValidTargetPubkey, AmountSats: 1000, MaxFeePct: null); await FluentActions.Awaiting(() => service.RebalanceAsync(request)) .Should().ThrowAsync(); } + [Fact] + public async Task RebalanceAsync_SourceChannelIdMissing_Throws() + { + var service = CreateService(); + var request = new RebalanceRequest(NodeId: 1, SourceChannelId: 0, ValidTargetPubkey, + AmountSats: 1000, MaxFeePct: null); + + await FluentActions.Awaiting(() => service.RebalanceAsync(request)) + .Should().ThrowAsync() + .WithMessage("*Source channel id is required*"); + } + + [Fact] + public async Task RebalanceAsync_TargetPubkeyEmpty_Throws() + { + var service = CreateService(); + var request = new RebalanceRequest(NodeId: 1, ValidChannelId, TargetPubkey: " ", + AmountSats: 1000, MaxFeePct: null); + + await FluentActions.Awaiting(() => service.RebalanceAsync(request)) + .Should().ThrowAsync() + .WithMessage("*Target pubkey is required*"); + } + + [Fact] + public async Task RebalanceAsync_SourceChannelNotFound_Throws() + { + // ValidChannelId is pre-stubbed in the constructor; this test overrides to null so the + // source-channel lookup fails after the args pass the cheap validators. + var node = CreateNode(); + _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); + _channelRepo.Setup(x => x.GetById(ValidChannelId)).ReturnsAsync((Channel?)null); + + var service = CreateService(); + var request = new RebalanceRequest(node.Id, ValidChannelId, ValidTargetPubkey, + AmountSats: 100_000, MaxFeePct: null); + + await FluentActions.Awaiting(() => service.RebalanceAsync(request)) + .Should().ThrowAsync() + .WithMessage($"*Source channel {ValidChannelId} not found*"); + } + + [Fact] + public async Task RebalanceAsync_TargetPubkeyEqualsSourceCounterparty_Throws() + { + // Pinning the LastHopPubkey to the same peer that's already the source channel's + // counterparty would route the sats straight back to where they came from — a no-op + // rebalance. The service must reject this. + var node = CreateNode(); + _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); + // The default StubChannelRepo + StubCounterpartyNode in the ctor already sets up a + // channel where node 1 is the source and node 2 (with CounterpartyPubkey) is the + // destination. Asking the service to pin the last hop to CounterpartyPubkey hits the + // guard. + var service = CreateService(); + var request = new RebalanceRequest(node.Id, ValidChannelId, TargetPubkey: CounterpartyPubkey, + AmountSats: 100_000, MaxFeePct: null); + + await FluentActions.Awaiting(() => service.RebalanceAsync(request)) + .Should().ThrowAsync() + .WithMessage("*same as the source channel's counterparty peer*"); + } + + [Fact] + public async Task RebalanceAsync_TargetPubkeyEqualsSourceCounterparty_NodeIsDestination_Throws() + { + // Same guard, mirrored: the local node is the channel's DESTINATION (peer opened the + // channel to us). Counterparty is then the SourceNode side. Override the channel stub + // to flip the orientation; the counterparty node lookup still returns CounterpartyPubkey + // via the constructor's StubCounterpartyNode. + var node = CreateNode(); + _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); + _channelRepo.Setup(x => x.GetById(ValidChannelId)).ReturnsAsync(new Channel + { + Id = ValidChannelId, + ChanId = 67890UL, + SatsAmount = 1_000_000, + Status = Channel.ChannelStatus.Open, + FundingTx = "tx", + // Peer opened the channel to us: counterparty sits on the source side. + SourceNodeId = CounterpartyNodeId, + DestinationNodeId = node.Id, + }); + + var service = CreateService(); + var request = new RebalanceRequest(node.Id, ValidChannelId, TargetPubkey: CounterpartyPubkey, + AmountSats: 100_000, MaxFeePct: null); + + await FluentActions.Awaiting(() => service.RebalanceAsync(request)) + .Should().ThrowAsync() + .WithMessage("*same as the source channel's counterparty peer*"); + } + [Fact] public async Task RebalanceAsync_UserSuppliedFeePct_PersistedAsIs() { @@ -158,7 +306,7 @@ public async Task RebalanceAsync_UserSuppliedFeePct_PersistedAsIs() .ReturnsAsync(new ProbeResult.NoRoute("test")); var service = CreateService(); - var request = new RebalanceRequest(NodeId: node.Id, null, null, + var request = new RebalanceRequest(NodeId: node.Id, ValidChannelId, ValidTargetPubkey, AmountSats: 100_000, MaxFeePct: 0.1234); var result = await service.RebalanceAsync(request); @@ -183,7 +331,7 @@ public async Task RebalanceAsync_UserSuppliedProbeBackoffRatio_PersistedAndPasse .ReturnsAsync(new ProbeResult.NoRoute("test")); var service = CreateService(); - var request = new RebalanceRequest(NodeId: node.Id, null, null, + var request = new RebalanceRequest(NodeId: node.Id, ValidChannelId, ValidTargetPubkey, AmountSats: 100_000, MaxFeePct: 0.025, ProbeBackoffRatio: 0.8); var result = await service.RebalanceAsync(request); @@ -208,7 +356,7 @@ public async Task RebalanceAsync_NullProbeBackoffRatio_FallsBackToConstant() .ReturnsAsync(new ProbeResult.NoRoute("test")); var service = CreateService(); - var request = new RebalanceRequest(NodeId: node.Id, null, null, + var request = new RebalanceRequest(NodeId: node.Id, ValidChannelId, ValidTargetPubkey, AmountSats: 100_000, MaxFeePct: 0.025, ProbeBackoffRatio: null); var result = await service.RebalanceAsync(request); @@ -223,7 +371,8 @@ public async Task RebalanceAsync_NoUserFeePct_FallsBackToDefaultFeePct() // When no MaxFeePct is supplied the service must derive it from // Constants.REBALANCE_DEFAULT_MAX_FEE_PCT (0.05). No LND outbound-rate call should be made. var node = CreateNode(); - var peerPubkey = "030000000000000000000000000000000000000000000000000000000000000002"; + // Use a pubkey distinct from CounterpartyPubkey so the no-op guard doesn't trip. + var peerPubkey = ValidTargetPubkey; _nodeRepo.Setup(x => x.GetById(node.Id, It.IsAny())).ReturnsAsync(node); StubRepoForCapture(); _lightning.Setup(x => x.AddInvoiceAsync(node, It.IsAny(), It.IsAny(), It.IsAny())) @@ -238,7 +387,7 @@ public async Task RebalanceAsync_NoUserFeePct_FallsBackToDefaultFeePct() .ReturnsAsync(new Payment { Status = Payment.Types.PaymentStatus.Succeeded, FeeMsat = 1000 }); var service = CreateService(); - var request = new RebalanceRequest(NodeId: node.Id, null, TargetPubkey: peerPubkey, + var request = new RebalanceRequest(NodeId: node.Id, ValidChannelId, TargetPubkey: peerPubkey, AmountSats: 100_000, MaxFeePct: null); var result = await service.RebalanceAsync(request); @@ -277,7 +426,7 @@ public async Task ExecuteAsync_ProbeSucceeds_PaymentSucceeds_StatusSucceeded() }); var service = CreateService(); - var request = new RebalanceRequest(node.Id, null, null, 100_000, MaxFeePct: 0.05); + var request = new RebalanceRequest(node.Id, ValidChannelId, ValidTargetPubkey, 100_000, MaxFeePct: 0.05); var result = await service.RebalanceAsync(request); result.Status.Should().Be(RebalanceStatus.Succeeded); @@ -306,7 +455,7 @@ public async Task ExecuteAsync_InvoiceExpiryCoversFullRetryWindowPlusBuffer() var service = CreateService(); // TimeoutSeconds=60, MaxAttempts=3 → backoff = 60 + 120 = 180 → 60 + 180 + buffer. - var request = new RebalanceRequest(node.Id, null, null, 100_000, MaxFeePct: 0.05, + var request = new RebalanceRequest(node.Id, ValidChannelId, ValidTargetPubkey, 100_000, MaxFeePct: 0.05, TimeoutSeconds: 60, MaxAttempts: 3); await service.RebalanceAsync(request); @@ -335,7 +484,7 @@ public async Task ExecuteAsync_InvoiceExpiry_MaxAttempts1_OmitsBackoffSum() .ReturnsAsync(new ProbeResult.NoRoute("stop")); var service = CreateService(); - var request = new RebalanceRequest(node.Id, null, null, 100_000, MaxFeePct: 0.05, + var request = new RebalanceRequest(node.Id, ValidChannelId, ValidTargetPubkey, 100_000, MaxFeePct: 0.05, TimeoutSeconds: 45, MaxAttempts: 1); await service.RebalanceAsync(request); @@ -376,7 +525,7 @@ public async Task ExecuteAsync_PersistsPaymentHashHexBeforePayment() .ReturnsAsync(new ProbeResult.NoRoute("test")); var service = CreateService(); - var request = new RebalanceRequest(node.Id, null, null, 100_000, MaxFeePct: 0.05); + var request = new RebalanceRequest(node.Id, ValidChannelId, ValidTargetPubkey, 100_000, MaxFeePct: 0.05); var result = await service.RebalanceAsync(request); result.PaymentHashHex.Should().Be("abcdef01"); @@ -396,7 +545,7 @@ public async Task ExecuteAsync_ProbeNoRoute_StatusNoRoute_RetryScheduled() .ReturnsAsync(new ProbeResult.NoRoute("exhausted")); var service = CreateService(); - var request = new RebalanceRequest(node.Id, null, null, 100_000, MaxFeePct: 0.05); + var request = new RebalanceRequest(node.Id, ValidChannelId, ValidTargetPubkey, 100_000, MaxFeePct: 0.05); var result = await service.RebalanceAsync(request); // After scheduling a retry, AttemptNumber is bumped to 2 and Status is reset to Pending. @@ -426,7 +575,7 @@ public async Task ExecuteAsync_PaymentInsufficientBalance_NoRetryScheduled() }); var service = CreateService(); - var request = new RebalanceRequest(node.Id, null, null, 100_000, MaxFeePct: 0.05); + var request = new RebalanceRequest(node.Id, ValidChannelId, ValidTargetPubkey, 100_000, MaxFeePct: 0.05); var result = await service.RebalanceAsync(request); result.Status.Should().Be(RebalanceStatus.InsufficientBalance); @@ -449,7 +598,7 @@ public async Task ExecuteAsync_ScheduleRetry_EscalatesFeePctFromInitialToRetry() .ReturnsAsync(new ProbeResult.NoRoute()); var service = CreateService(); - var request = new RebalanceRequest(node.Id, null, TargetPubkey: null, + var request = new RebalanceRequest(node.Id, ValidChannelId, TargetPubkey: ValidTargetPubkey, AmountSats: 100_000, MaxFeePct: null); var result = await service.RebalanceAsync(request); @@ -476,7 +625,7 @@ public async Task ExecuteAsync_PerRowRetryMaxFeePct_OverridesConstantOnEscalatio // Per-row retry pct = 0.09%, overriding the constant default // (REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT = 0.05%). After one NoRoute the // escalated cap should be max(initial=0.05%, retry=0.09%) = 0.09%. - var request = new RebalanceRequest(node.Id, null, TargetPubkey: null, + var request = new RebalanceRequest(node.Id, ValidChannelId, TargetPubkey: ValidTargetPubkey, AmountSats: 100_000, MaxFeePct: null, RetryMaxFeePct: 0.09); var result = await service.RebalanceAsync(request); @@ -499,7 +648,7 @@ public async Task ExecuteAsync_PerRowMaxAttempts1_NoRetryScheduled() var service = CreateService(); // MaxAttempts=1 → first attempt is also the last; no Quartz retry should be scheduled. - var request = new RebalanceRequest(node.Id, null, null, + var request = new RebalanceRequest(node.Id, ValidChannelId, ValidTargetPubkey, AmountSats: 100_000, MaxFeePct: 0.025, MaxAttempts: 1); var result = await service.RebalanceAsync(request); @@ -559,7 +708,7 @@ public async Task RebalanceAsync_AuditsInitiation() .ReturnsAsync(new ProbeResult.NoRoute()); var service = CreateService(); - await service.RebalanceAsync(new RebalanceRequest(node.Id, null, null, AmountSats: 100_000, MaxFeePct: 0.1, TimeoutSeconds: 500)); + await service.RebalanceAsync(new RebalanceRequest(node.Id, ValidChannelId, ValidTargetPubkey, AmountSats: 100_000, MaxFeePct: 0.1, TimeoutSeconds: 500)); _audit.Verify(a => a.LogAsync( AuditActionType.RebalanceInitiated, @@ -739,7 +888,7 @@ public async Task ExecuteAsync_FirstAttempt_NoPriorHash_SkipsPreflightTrack() .ReturnsAsync(new ProbeResult.NoRoute("test")); var service = CreateService(); - await service.RebalanceAsync(new RebalanceRequest(node.Id, null, null, 100_000, MaxFeePct: 0.05)); + await service.RebalanceAsync(new RebalanceRequest(node.Id, ValidChannelId, ValidTargetPubkey, 100_000, MaxFeePct: 0.05)); _lightning.Verify(x => x.TrackPaymentV2Async(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);