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
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);
}
22 changes: 22 additions & 0 deletions src/Proto/nodeguard.proto
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ service NodeGuardService {
Adds tags to UTXOs under the treasury
*/
rpc AddTags(AddTagsRequest) returns (AddTagsResponse);

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

message GetLiquidityRulesRequest {
Expand Down Expand Up @@ -444,3 +449,20 @@ message AddTagsRequest {

message AddTagsResponse {
}

message SetChannelFeePolicyRequest {
string chan_point = 1;
int64 base_fee_msat = 2;
uint32 fee_rate_ppm = 3;
uint32 time_lock_delta = 4;
optional InboundFeePolicy inbound_fee_policy = 5;
string node_pubkey = 6;
}

message InboundFeePolicy {
int32 base_fee_msat = 1;
int32 fee_rate_ppm = 2;
}

message SetChannelFeePolicyResponse {
}
19 changes: 19 additions & 0 deletions src/Rpc/NodeGuardService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ Task<GetNewWalletAddressResponse> GetNewWalletAddress(GetNewWalletAddressRequest
Task<GetChannelResponse> GetChannel(GetChannelRequest request, ServerCallContext context);

Task<AddTagsResponse> AddTags(AddTagsRequest request, ServerCallContext context);

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

/// <summary>
Expand Down Expand Up @@ -1203,4 +1205,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 @@ -44,6 +44,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 @@ -336,4 +338,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 } });
}
}
102 changes: 101 additions & 1 deletion 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 @@ -142,6 +143,19 @@ public interface ILightningService
/// <param name="timeout">Optional timeout in seconds</param>
/// <returns></returns>
public Task<RouteFeeResponse?> EstimateRouteFee(string destPubkey, long amountSat, string? paymentRequest = null, uint timeout = 30);

/// <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);
}

public class LightningService : ILightningService
Expand Down Expand Up @@ -1528,5 +1542,91 @@ public async Task<Dictionary<ulong, ChannelState>> GetChannelsState()
return null;
}
}

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.");
}

var channel = await _channelRepository.GetByOutpoint(outPoint);
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 (!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