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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ test/NodeGuard.Tests/obj
devnetwork*/
.DS_Store
# NodeGuard macaroon environment file
src/nodeguard-macaroons.env
src/nodeguard-macaroons.env
.claude/settings.local.json
8 changes: 8 additions & 0 deletions src/Data/ApplicationDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
modelBuilder.Entity<ForwardingHtlcEvent>()
.HasIndex(x => x.EventTimestamp);

modelBuilder.Entity<Rebalance>()
.HasOne(r => r.SourceChannel)
.WithMany()
.HasForeignKey(r => r.SourceChannelId)
.OnDelete(DeleteBehavior.Restrict);

base.OnModelCreating(modelBuilder);
}

Expand Down Expand Up @@ -138,6 +144,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)

public DbSet<SwapOut> SwapOuts { get; set; }

public DbSet<Rebalance> Rebalances { get; set; }

public DbSet<AuditLog> AuditLogs { get; set; }

public DbSet<ForwardingHtlcEvent> ForwardingHtlcEvents { get; set; }
Expand Down
11 changes: 9 additions & 2 deletions src/Data/Models/AuditLog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,13 @@ public enum AuditActionType
BumpFee,

// Signing
Sign
Sign,

// Rebalance Operations
RebalanceInitiated,
RebalanceProbing,
RebalanceCompleted,
RebalanceRetryScheduled
}

/// <summary>
Expand Down Expand Up @@ -185,5 +191,6 @@ public enum AuditObjectType
Key,
UTXO,
InternalWallet,
Session
Session,
Rebalance
}
135 changes: 135 additions & 0 deletions src/Data/Models/Rebalance.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* 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.ComponentModel.DataAnnotations.Schema;
using NBitcoin;

namespace NodeGuard.Data.Models;

public enum RebalanceStatus
{
Pending,
Probing,
InFlight,
Succeeded,
Failed,
NoRoute,
Timeout,
InsufficientBalance,
ExceededFeeLimit
}

public class Rebalance : Entity
{
public int NodeId { get; set; }
public Node? Node { get; set; }

/// <summary>
/// Pubkey of the source node, stored for simpler analytics
/// </summary>
public string? SourceNodePubKey { get; set; }

public RebalanceStatus Status { get; set; }

public bool IsManual { get; set; }

public int AttemptNumber { get; set; } = 1;

/// <summary>
/// The amount the user originally requested to rebalance.
/// </summary>
public long RequestedAmountSats { get; set; }

/// <summary>
/// The amount actually used for the payment. May be lower than RequestedAmountSats
/// when the prober had to reduce the amount to find a viable route.
/// </summary>
public long SatsAmount { get; set; }

[NotMapped]
public decimal Amount => new Money(SatsAmount, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC);

[NotMapped]
public decimal RequestedAmount => new Money(RequestedAmountSats, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC);

/// <summary>
/// Maximum fee as percentage of <see cref="SatsAmount"/> (0.05 = 0.05%).
/// Escalates on retry per <see cref="RetryMaxFeePct"/> or Constants.
/// </summary>
public double MaxFeePct { get; set; }
/// <summary>
/// Maximum fee percent done in retries, falling to a user-supplied value or defaulting to Constants a retry is needed.
/// </summary>
public double? RetryMaxFeePct { get; set; }
Comment thread
markettes marked this conversation as resolved.
public long? FeePaidSats { get; set; }

public long? FeePaidMsat { get; set; }

[NotMapped]
public long? EffectivePpm => FeePaidMsat.HasValue && SatsAmount > 0
? FeePaidMsat.Value * 1_000L / SatsAmount
: (long?)null;

[NotMapped]
public Money FeePaid => new Money(FeePaidSats ?? 0, MoneyUnit.Satoshi);

public int? SourceChannelId { get; set; }
public Channel? SourceChannel { get; set; }

/// <summary>
/// LND chan_id of the source channel at the time of the request.
/// </summary>
public ulong? SourceChanIdLnd { get; set; }

/// <summary>
/// Last-hop peer pubkey which constrains the receiving PEER of the circular payment — not the receiving channel.
/// Lightning nodes choose which channel between this node and that peer to use as the last hop, so theres no technical guarantee which channel will be used, but in practice this is sufficient to balance out source channels.
/// When null, LND picks any inbound peer that satisfies the cost cap.
/// </summary>
public string? TargetPubkey { get; set; }

/// <summary>
/// Persisted for forensic / proof-of-payment lookup; intentionally not exposed via gRPC.
/// </summary>
public string? PreimageHex { get; set; }

public string? UserRequestorId { get; set; }
public ApplicationUser? UserRequestor { get; set; }

/// <summary>
/// Pathfinding/payment timeout we passed to SendPaymentV2.
/// </summary>
public int TimeoutSeconds { get; set; } = 60;

/// <summary>
/// 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 <c>Constants.REBALANCE_PROBE_BACKOFF_RATIO</c>.
/// </summary>
public double? ProbeBackoffRatio { get; set; }

/// <summary>
/// Maximum number of attempts (including the first try). When null, falls back to
/// <c>Constants.REBALANCE_MAX_ATTEMPTS</c>
/// </summary>
public int? MaxAttempts { get; set; }


}
49 changes: 49 additions & 0 deletions src/Data/Repositories/Interfaces/IRebalanceRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* 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 NBitcoin;
using NodeGuard.Data.Models;

namespace NodeGuard.Data.Repositories.Interfaces;

public interface IRebalanceRepository
{
Task<Rebalance?> GetById(int id);

Task<(List<Rebalance> rebalances, int totalCount)> GetPaginatedAsync(
int pageNumber,
int pageSize,
RebalanceStatus? status = null,
int? nodeId = null,
int? sourceChannelId = null,
string? userId = null,
bool? isManual = null,
DateTimeOffset? fromDate = null,
DateTimeOffset? toDate = null);

/// <summary>
/// Get all rebalances currently in flight (Pending/Probing/InFlight). Used by the
/// monitor job for stale-state cleanup after process restart.
/// </summary>
Task<List<Rebalance>> GetAllInFlight();

Task<(bool, string?)> AddAsync(Rebalance rebalance);

(bool, string?) Update(Rebalance rebalance);
}
133 changes: 133 additions & 0 deletions src/Data/Repositories/RebalanceRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
* 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 Microsoft.EntityFrameworkCore;
using NBitcoin;
using NodeGuard.Data.Models;
using NodeGuard.Data.Repositories.Interfaces;

namespace NodeGuard.Data.Repositories;

public class RebalanceRepository : IRebalanceRepository
{
private readonly IRepository<Rebalance> _repository;
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;

public RebalanceRepository(IRepository<Rebalance> repository, IDbContextFactory<ApplicationDbContext> dbContextFactory)
{
_repository = repository;
_dbContextFactory = dbContextFactory;
}

public async Task<Rebalance?> GetById(int id)
{
await using var context = await _dbContextFactory.CreateDbContextAsync();

return await context.Rebalances
.Include(r => r.Node)
.Include(r => r.SourceChannel)
.Include(r => r.UserRequestor)
.SingleOrDefaultAsync(r => r.Id == id);
}

public async Task<(List<Rebalance> rebalances, int totalCount)> GetPaginatedAsync(
int pageNumber,
int pageSize,
RebalanceStatus? status = null,
int? nodeId = null,
int? sourceChannelId = null,
string? userId = null,
bool? isManual = null,
DateTimeOffset? fromDate = null,
DateTimeOffset? toDate = null)
{
await using var context = await _dbContextFactory.CreateDbContextAsync();

var query = context.Rebalances
.Include(r => r.Node)
.Include(r => r.SourceChannel)
.ThenInclude(c => c!.SourceNode)
.Include(r => r.SourceChannel)
.ThenInclude(c => c!.DestinationNode)
.Include(r => r.UserRequestor)
.AsQueryable();

if (status.HasValue)
query = query.Where(r => r.Status == status.Value);

if (nodeId.HasValue)
query = query.Where(r => r.NodeId == nodeId.Value);

if (sourceChannelId.HasValue)
query = query.Where(r => r.SourceChannelId == sourceChannelId.Value);

if (!string.IsNullOrEmpty(userId))
query = query.Where(r => r.UserRequestorId == userId);

if (isManual.HasValue)
query = query.Where(r => r.IsManual == isManual.Value);

if (fromDate.HasValue)
query = query.Where(r => r.CreationDatetime >= fromDate.Value);

if (toDate.HasValue)
query = query.Where(r => r.CreationDatetime <= toDate.Value);

query = query.OrderByDescending(r => r.CreationDatetime);

var totalCount = await query.CountAsync();

var rebalances = await query
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();

return (rebalances, totalCount);
}

public async Task<List<Rebalance>> GetAllInFlight()
{
await using var context = await _dbContextFactory.CreateDbContextAsync();

return await context.Rebalances
.Where(r => r.Status == RebalanceStatus.Pending
|| r.Status == RebalanceStatus.Probing
|| r.Status == RebalanceStatus.InFlight)
.ToListAsync();
}

public async Task<(bool, string?)> AddAsync(Rebalance rebalance)
{
await using var context = await _dbContextFactory.CreateDbContextAsync();

rebalance.SetCreationDatetime();
rebalance.SetUpdateDatetime();

return await _repository.AddAsync(rebalance, context);
}

public (bool, string?) Update(Rebalance rebalance)
{
using var context = _dbContextFactory.CreateDbContext();

rebalance.SetUpdateDatetime();

return _repository.Update(rebalance, context);
}
}
Loading
Loading