Skip to content
Merged
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
7 changes: 7 additions & 0 deletions src/Data/Repositories/ChannelRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ public ChannelRepository(IRepository<Channel> repository,
return await applicationDbContext.Channels.Include(x => x.ChannelOperationRequests).FirstOrDefaultAsync(x => x.ChanId.Equals(chanId));
}

public async Task<Channel?> GetByOutpoint(OutPoint outpoint)
{
await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync();

return await applicationDbContext.Channels.FirstOrDefaultAsync(c => c.FundingTx == outpoint.Hash.ToString() && c.FundingTxOutputIndex == outpoint.N);
}

public async Task<List<Channel>> GetAll()
{
await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync();
Expand Down
6 changes: 6 additions & 0 deletions src/Data/Repositories/Interfaces/IChannelRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
*
*/

using NBitcoin;
using Channel = NodeGuard.Data.Models.Channel;

namespace NodeGuard.Data.Repositories.Interfaces;
Expand Down Expand Up @@ -68,4 +69,9 @@ public interface IChannelRepository
string? channelIdFilter = null,
DateTimeOffset? fromDate = null,
DateTimeOffset? toDate = null);

/// <summary>
/// Retrieves the channel by the outpoint
/// </summary>
Task<Channel?> GetByOutpoint(OutPoint outpoint);
}
30 changes: 30 additions & 0 deletions src/Proto/nodeguard.proto
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ service NodeGuardService {
Returns a paginated list of rebalances with optional filters.
*/
rpc GetRebalances(GetRebalancesRequest) returns (GetRebalancesResponse);

/*
Sets the fee policy for a channel
*/
rpc SetChannelFeePolicy(SetChannelFeePolicyRequest) returns (SetChannelFeePolicyResponse);
}

message GetLiquidityRulesRequest {
Expand Down Expand Up @@ -460,6 +465,31 @@ message AddTagsRequest {
message AddTagsResponse {
}

message SetChannelFeePolicyRequest {
Comment thread
Jossec101 marked this conversation as resolved.
// Channel point in the format <funding_txid>:<output_index>.
string chan_point = 1;
// Outbound base fee charged for forwarded HTLCs, in millisatoshis.
int64 base_fee_msat = 2;
// Outbound proportional fee rate in parts-per-million (ppm).
uint32 fee_rate_ppm = 3;
// Outbound CLTV delta (final timelock delta) in blocks.
uint32 time_lock_delta = 4;
// Optional inbound fee policy (peer->this node direction).
optional InboundFeePolicy inbound_fee_policy = 5;
// Managed node pubkey (33-byte hex) where the policy will be applied.
string node_pubkey = 6;
}

message InboundFeePolicy {
// Inbound base fee in millisatoshis. Signed to support discounts/rebates.
int32 base_fee_msat = 1;
// Inbound proportional fee rate in ppm. Signed to support discounts/rebates.
int32 fee_rate_ppm = 2;
}

message SetChannelFeePolicyResponse {
}

enum REBALANCE_STATUS {
REBALANCE_PENDING = 0;
REBALANCE_PROBING = 1;
Expand Down
19 changes: 19 additions & 0 deletions src/Rpc/NodeGuardService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ Task<GetNewWalletAddressResponse> GetNewWalletAddress(GetNewWalletAddressRequest
Task<GetRebalanceResponse> GetRebalance(GetRebalanceRequest request, ServerCallContext context);

Task<GetRebalancesResponse> GetRebalances(GetRebalancesRequest request, ServerCallContext context);

Task<SetChannelFeePolicyResponse> SetChannelFeePolicy(SetChannelFeePolicyRequest request, ServerCallContext context);
}

/// <summary>
Expand Down Expand Up @@ -1368,4 +1370,21 @@ private bool ValidateBitcoinAddress(string address)

return true;
}

public override async Task<SetChannelFeePolicyResponse> SetChannelFeePolicy(SetChannelFeePolicyRequest request, ServerCallContext context)
{
try {
await _lightningService.SetChannelFeePolicy(request.ChanPoint, request.NodePubkey, request.BaseFeeMsat, request.FeeRatePpm, request.TimeLockDelta, request.InboundFeePolicy?.BaseFeeMsat, request.InboundFeePolicy?.FeeRatePpm);
}
catch (ArgumentException e)
{
throw new RpcException(new Status(StatusCode.InvalidArgument, e.Message));
}
catch (Exception e)
{
throw new RpcException(new Status(StatusCode.Internal, e.Message));
}

return new SetChannelFeePolicyResponse();
}
}
30 changes: 30 additions & 0 deletions src/Services/LightningClientService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ public interface ILightningClientService
public void FundingStateStepVerify(Node node, PSBT finalizedPSBT, byte[] pendingChannelId, Lightning.LightningClient? client = null);
public void FundingStateStepFinalize(Node node, PSBT finalizedPSBT, byte[] pendingChannelId, Lightning.LightningClient? client = null);
public void FundingStateStepCancel(Node node, byte[] pendingChannelId, Lightning.LightningClient? client = null);

public Task<PolicyUpdateResponse?> SetChannelFeePolicy(Node node, NBitcoin.OutPoint chanPoint, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm, Lightning.LightningClient? client = null);
}

public class LightningClientService : ILightningClientService
Expand Down Expand Up @@ -377,4 +379,32 @@ public void FundingStateStepCancel(Node node, byte[] pendingChannelId, Lightning
}
}, new Metadata { { "macaroon", node.ChannelAdminMacaroon } });
}

public async Task<PolicyUpdateResponse?> SetChannelFeePolicy(Node node, NBitcoin.OutPoint chanPoint, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm, Lightning.LightningClient? client = null)
{
client ??= GetLightningClient(node.Endpoint);

var request = new PolicyUpdateRequest
{
ChanPoint = new ChannelPoint
{
FundingTxidStr = chanPoint.Hash.ToString(),
OutputIndex = chanPoint.N
},
BaseFeeMsat = baseFeeMsat,
FeeRatePpm = feeRatePpm,
TimeLockDelta = timeLockDelta
};

if (inboundBaseFeeMsat.HasValue && inboundFeeRatePpm.HasValue)
{
request.InboundFee = new InboundFee
{
BaseFeeMsat = inboundBaseFeeMsat.Value,
FeeRatePpm = inboundFeeRatePpm.Value
};
}

return await client.UpdateChannelPolicyAsync(request, new Metadata { { "macaroon", node.ChannelAdminMacaroon } });
}
}
119 changes: 117 additions & 2 deletions src/Services/LightningService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
using Routerrpc;
using Channel = NodeGuard.Data.Models.Channel;
using Transaction = NBitcoin.Transaction;
using OutPoint = NBitcoin.OutPoint;

// ReSharper disable InconsistentNaming

Expand Down Expand Up @@ -184,7 +185,20 @@ Task<Payment> SendPaymentV2Async(Node node, string paymentRequest, long feeLimit
/// a specific channel. If the peer has multiple channels with us, the first one
/// returned by ListChannels is used as a representative.
/// </summary>
Task<long?> GetLocalOutboundFeeRatePpmByPeerAsync(Node node, string peerPubkey);
Task<long?> GetLocalOutboundFeeRatePpmByPeerAsync
(Node node, string peerPubkey);
/// <summary>
/// Sets the channel fee policy for a given channel identified by its chanPoint
/// </summary>
/// <param name="chanPoint"></param>
/// <param name="nodePubKey"></param>
/// <param name="baseFeeMsat"></param>
/// <param name="feeRatePpm"></param>
/// <param name="timeLockDelta"></param>
/// <param name="inboundBaseFeeMsat"></param>
/// <param name="inboundFeeRatePpm"></param>
/// <returns></returns>
public Task SetChannelFeePolicy(string chanPoint, string nodePubKey, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm);
}

/// <summary>
Expand Down Expand Up @@ -1801,5 +1815,106 @@ public async Task<Payment> SendPaymentV2Async(Node node, string paymentRequest,

return await GetLocalOutboundFeeRatePpmAsync(node, match.ChanId);
}

public async Task SetChannelFeePolicy(string chanPoint, string nodePubKey, long baseFeeMsat, uint feeRatePpm, uint timeLockDelta, int? inboundBaseFeeMsat, int? inboundFeeRatePpm)
{
// Validate chanPoint format
if (!OutPoint.TryParse(chanPoint, out var outPoint))
{
throw new ArgumentException("Invalid chanPoint format.", nameof(chanPoint));
}

if (string.IsNullOrWhiteSpace(nodePubKey))
{
throw new ArgumentException("Node pubkey is required.", nameof(nodePubKey));
}

// Validate inbound fee policy parameters
if (inboundBaseFeeMsat.HasValue != inboundFeeRatePpm.HasValue)
{
throw new ArgumentException("Both inboundBaseFeeMsat and inboundFeeRatePpm must be provided together for inbound fee policy.");
}

if (inboundBaseFeeMsat.HasValue && inboundBaseFeeMsat.Value > 0)
{
throw new ArgumentException("Inbound base fee must be lower or equal to zero.", nameof(inboundBaseFeeMsat));
}

if (inboundFeeRatePpm.HasValue && inboundFeeRatePpm.Value > 0)
{
throw new ArgumentException("Inbound fee rate must be lower or equal to zero.", nameof(inboundFeeRatePpm));
}

var channel = await _channelRepository.GetByOutpoint(outPoint);
Comment thread
Jossec101 marked this conversation as resolved.
if (channel == null)
{
throw new ArgumentException("Channel not found for the given chanPoint.", nameof(chanPoint));
}

var node = await _nodeRepository.GetByPubkey(nodePubKey);
if (node == null)
{
throw new ArgumentException("Node not found for the given nodePubKey.", nameof(nodePubKey));
}

if (channel.SourceNodeId != node.Id && channel.DestinationNodeId != node.Id)
{
throw new ArgumentException("The given nodePubKey is not a participant of the channel.", nameof(nodePubKey));
}

if (!node.IsManaged || string.IsNullOrWhiteSpace(node.ChannelAdminMacaroon))
{
throw new ArgumentException("The given nodePubKey is not a managed node with channel admin access.", nameof(nodePubKey));
}

try
{
var response = await _lightningClientService.SetChannelFeePolicy(node, outPoint, baseFeeMsat, feeRatePpm, timeLockDelta, inboundBaseFeeMsat, inboundFeeRatePpm);

if (response?.FailedUpdates != null && response.FailedUpdates.Count > 0)
{
_logger.LogError("Failed to update fee policy for channel: {ChanPoint}", chanPoint);
throw new Exception($"Failed to update fee policy for channel: {chanPoint}");
}
}
catch (Exception e)
{
_logger.LogError(e, "Error while setting channel fee policy for chanPoint: {ChanPoint}", chanPoint);
throw new Exception($"Error while setting channel fee policy for chanPoint: {chanPoint}");
}

try
{
await using var applicationDbContext = await _dbContextFactory.CreateDbContextAsync();

await applicationDbContext.AuditLogs.AddAsync(new AuditLog
{
ActionType = AuditActionType.Update,
EventType = AuditEventType.Success,
ObjectAffected = AuditObjectType.Channel,
ObjectId = channel.Id.ToString(),
Username = "SYSTEM",
Details = System.Text.Json.JsonSerializer.Serialize(new
{
ChanPoint = chanPoint,
ChannelId = channel.Id,
channel.ChanId,
NodeId = node.Id,
NodePubKey = node.PubKey,
BaseFeeMsat = baseFeeMsat,
FeeRatePpm = feeRatePpm,
TimeLockDelta = timeLockDelta,
InboundBaseFeeMsat = inboundBaseFeeMsat,
InboundFeeRatePpm = inboundFeeRatePpm
})
});

await applicationDbContext.SaveChangesAsync();
}
catch (Exception e)
{
_logger.LogError(e, "Error while saving channel fee policy audit log for chanPoint: {ChanPoint}", chanPoint);
}
}
}
}
}
55 changes: 54 additions & 1 deletion test/NodeGuard.Tests/Data/Repositories/ChannelRepositoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
using Lnrpc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin;
using Quartz;
using Channel = NodeGuard.Data.Models.Channel;

Expand Down Expand Up @@ -186,4 +187,56 @@ public async Task MarkAsClosed_Negative_ChannelFound()
channel.Status.Should().Be(Channel.ChannelStatus.Open);
result.Item2.Should().NotBeNull();
}
}

[Fact]
public async Task GetByOutpoint_ReturnsMatchingChannel()
{
// Arrange
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: $"ChannelRepositoryTests_{Guid.NewGuid()}")
.Options;
var dbContextFactory = new Mock<IDbContextFactory<ApplicationDbContext>>();
dbContextFactory
.Setup(x => x.CreateDbContextAsync(default))
.ReturnsAsync(() => new ApplicationDbContext(options));

var fundingTx = "0000000000000000000000000000000000000000000000000000000000000001";
await using (var context = new ApplicationDbContext(options))
{
context.Channels.AddRange(
new Channel
{
Id = 1,
FundingTx = fundingTx,
FundingTxOutputIndex = 2,
ChanId = 100,
Status = Channel.ChannelStatus.Open
},
new Channel
{
Id = 2,
FundingTx = fundingTx,
FundingTxOutputIndex = 3,
ChanId = 101,
Status = Channel.ChannelStatus.Open
});
await context.SaveChangesAsync();
}

var channelRepository = new ChannelRepository(
new Mock<IRepository<Channel>>().Object,
new Mock<ILogger<ChannelRepository>>().Object,
dbContextFactory.Object,
new Mock<IChannelOperationRequestRepository>().Object,
new Mock<ISchedulerFactory>().Object,
new Mock<IMapper>().Object,
new Mock<ILightningClientService>().Object);

// Act
var result = await channelRepository.GetByOutpoint(NBitcoin.OutPoint.Parse($"{fundingTx}:2"));

// Assert
result.Should().NotBeNull();
result!.Id.Should().Be(1);
}
}
Loading
Loading