Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/Proto/nodeguard.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 8 additions & 2 deletions src/Rpc/NodeGuardService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1212,14 +1212,20 @@ public override async Task<RequestRebalanceResponse> 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,
Expand Down
42 changes: 32 additions & 10 deletions src/Services/RebalanceService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -104,18 +104,40 @@ public async Task<Rebalance> 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.
Expand Down
46 changes: 37 additions & 9 deletions src/Shared/NewRebalanceModal.razor
Original file line number Diff line number Diff line change
Expand Up @@ -58,26 +58,28 @@
</Field>
<Field>
<FieldLabel Class="d-flex">
Receiving peer <span class="text-muted">(optional)</span>
<Tooltip Class="ml-2" Text="Pins LND's LastHopPubkey to this peer — i.e. the rebalanced sats must come back to your node FROM this peer. You cannot pick which specific channel receives: if the peer has multiple channels with you, LND picks one. Leave blank to let LND choose any inbound peer." Placement="TooltipPlacement.Top">
Receiving peer
<Tooltip Class="ml-2" Text="Pins LND's LastHopPubkey to this peer — i.e. the rebalanced sats must come back to your node FROM this peer. You cannot pick which specific channel receives: if the peer has multiple channels with you, LND picks one." Placement="TooltipPlacement.Top">
<Icon Name="IconName.Info"/>
</Tooltip>
</FieldLabel>
<SelectList TItem="PeerOption"
TValue="string"
Data="@_peerOptions"
Data="@AvailableReceivingPeers"
SelectedValue="@(_selectedTargetPubkey ?? "")"
TextField="@((item) => item.DisplayName)"
ValueField="@((item) => item.Pubkey)"
SelectedValueChanged="OnTargetPeerChanged"
DefaultItemText="Any (let LND choose)">
DefaultItemText="Choose a receiving peer">
</SelectList>
<FieldHelp>
@if (string.IsNullOrEmpty(_selectedTargetPubkey))
@if (!string.IsNullOrEmpty(SourceChannelCounterpartyPubkey))
{
<span>No receiving peer pinned — LND will pick any inbound peer that satisfies the cost cap.</span>
<div style="color: #888;">
Source channel's counterparty (<strong>@SourceChannelCounterpartyPubkey.Substring(0, 10)…</strong>) is excluded — pinning the last hop to that peer would be a no-op.
</div>
}
else if (_targetOutboundPpm.HasValue)
@if (!string.IsNullOrEmpty(_selectedTargetPubkey) && _targetOutboundPpm.HasValue)
{
<span>Outbound fee rate to this peer: <strong>@_targetOutboundPpm.Value ppm</strong> (for reference only).</span>
}
Expand Down Expand Up @@ -110,7 +112,8 @@
@if (!string.IsNullOrEmpty(_selectedTargetPubkey))
{
<div style="font-size: 0.85em; color: #888; margin-top: 0.5rem;">
Receiving peer pinned: <strong>@_selectedTargetPubkey.Substring(0, 10)…</strong>. The exact channel that lands the sats is LND's call — no per-channel projection possible.
Receiving peer pinned: <strong>@_selectedTargetPubkey.Substring(0, 10)…</strong>. The exact
channel that lands the sats is LND's call — no per-channel projection possible.
</div>
}

Expand Down Expand Up @@ -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<PeerOption> AvailableReceivingPeers =>
string.IsNullOrEmpty(SourceChannelCounterpartyPubkey)
? _peerOptions
: _peerOptions.Where(p => p.Pubkey != SourceChannelCounterpartyPubkey).ToList();

private decimal CurrentInboundPct =>
_sourceLocalSats.HasValue && _sourceRemoteSats.HasValue
&& (_sourceLocalSats.Value + _sourceRemoteSats.Value) > 0
Expand All @@ -325,6 +343,8 @@
private bool CanSubmit =>
_selectedNode != null
&& _selectedSourceChannel != null
&& !string.IsNullOrEmpty(_selectedTargetPubkey)
&& _selectedTargetPubkey != SourceChannelCounterpartyPubkey
&& _sourceLocalSats.HasValue
&& ComputeAmountSats() > 0
&& _maxFeePct > 0m
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
56 changes: 54 additions & 2 deletions test/NodeGuard.Tests/Rpc/NodeGuardServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1435,14 +1435,58 @@ 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());

(await act.Should().ThrowAsync<RpcException>())
.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<RpcException>())
.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<RpcException>())
.Which.StatusCode.Should().Be(StatusCode.InvalidArgument);
}

[Fact]
public async Task RequestRebalance_Success_PassesOptionalsAndReturnsResponse()
{
Expand Down Expand Up @@ -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());

Expand Down
Loading
Loading