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
49 changes: 40 additions & 9 deletions docker/bitcoin/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,47 @@ ALICE_PUBKEY=$(node_pubkey $ALICE)
BOB_PUBKEY=$(node_pubkey $BOB)
CAROL_PUBKEY=$(node_pubkey $CAROL)

echo "Opening a channel from Alice to Bob"
lncli $ALICE openchannel --connect $BOB:9735 $BOB_PUBKEY --local_amt 16000000 --push_amt 8000000

echo "Opening a channel from Bob to Carol"
lncli $BOB openchannel --connect $CAROL:9735 $CAROL_PUBKEY --local_amt 16000000 --push_amt 8000000
# Topology for rebalance-friendly regtest:
#
# (~13M Alice / ~3M Bob) (~10M Bob / ~6M Carol)
# Alice ---------------------> Bob ---------------------> Carol
# ^ |
# | (~6M Alice / ~10M Carol — Alice has real inbound) |
# +-----------------------------------------------------
ALICE_TO_BOB_LOCAL=16000000
ALICE_TO_BOB_PUSH=3000000
BOB_TO_CAROL_LOCAL=16000000
BOB_TO_CAROL_PUSH=6000000
CAROL_TO_ALICE_LOCAL=16000000
CAROL_TO_ALICE_PUSH=10000000

echo "Opening a channel from Alice to Bob (heavy on Alice — drain source for rebalance tests)"
lncli $ALICE openchannel --connect $BOB:9735 $BOB_PUBKEY --local_amt $ALICE_TO_BOB_LOCAL --push_amt $ALICE_TO_BOB_PUSH

echo "Opening a channel from Bob to Carol (middle hop, balanced)"
lncli $BOB openchannel --connect $CAROL:9735 $CAROL_PUBKEY --local_amt $BOB_TO_CAROL_LOCAL --push_amt $BOB_TO_CAROL_PUSH

echo "Opening a channel from Carol to Alice (return path — Alice gets ~10M inbound from Carol)"
lncli $CAROL openchannel --connect $ALICE:9735 $ALICE_PUBKEY --local_amt $CAROL_TO_ALICE_LOCAL --push_amt $CAROL_TO_ALICE_PUSH

echo "Confirming channels"
bitcoin_cli -generate 6 > /dev/null

echo "Setting inbound fees on Bob's channels"


lncli $BOB updatechanpolicy --base_fee_msat 0 --fee_rate_ppm 1000 --time_lock_delta 40 --inbound_fee_rate_ppm -1000 --inbound_base_fee_msat 0
# Outbound fee rates are distinct per node so routing costs are non-trivial and
# visible in test assertions:
# Alice: 1000 ppm (outbound, not paid by Alice on her own circular payment)
# Bob: 400 ppm (paid when forwarding Alice→Carol leg)
# Carol: 600 ppm (paid when forwarding Carol→Alice leg)
# A circular rebalance Alice→Bob→Carol→Alice costs Bob(400) + Carol(600) = ~1000 ppm
# of the forwarded amount. On 1 000 000 sats that is 1 000 sats in fees — clearly
# non-zero and easy to assert against. Base fees are 0 on all nodes.
echo "Setting outbound fee policies on all nodes"
# Fees are intentionally distinct so routing costs are visible in test assertions:
# Alice -> Bob -> Carol -> Alice costs Bob's fee (400 ppm) + Carol's fee (600 ppm) = ~1000 ppm total.
# All base fees are 0 so the only cost is proportional to the forwarded amount.
lncli $ALICE updatechanpolicy --base_fee_msat 0 --fee_rate_ppm 1000 --time_lock_delta 40
lncli $BOB updatechanpolicy --base_fee_msat 0 --fee_rate_ppm 400 --time_lock_delta 40
lncli $CAROL updatechanpolicy --base_fee_msat 0 --fee_rate_ppm 600 --time_lock_delta 40

echo "Mining a few extra blocks so gossip can propagate"
bitcoin_cli -generate 3 > /dev/null
86 changes: 80 additions & 6 deletions src/Helpers/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,13 @@ public class Constants
public static readonly string DEFAULT_DERIVATION_PATH = "48'/1'";
public static readonly int SESSION_TIMEOUT_MILLISECONDS = 3_600_000;
public static readonly Money BITCOIN_DUST = new Money(0.00000546m, MoneyUnit.BTC); // 546 satoshi in BTC

/// <summary>
/// Minimum swap out size in BTC for automatic liquidity management (Swap Out).
/// Can be configured via MINIMUM_SWAP_OUT_SIZE_BTC environment variable.
/// </summary>
public static decimal MINIMUM_SWAP_OUT_SIZE_BTC = 0.01m;

/// <summary>
/// Maximum swap out size in BTC for automatic liquidity management (Swap Out).
/// Can be configured via MAXIMUM_SWAP_OUT_SIZE_BTC environment variable.
Expand All @@ -132,17 +132,66 @@ public class Constants
/// Maximum miner fees in satoshis for automatic swap operations
/// </summary>
public static long SWAP_MAX_MINER_FEES_SATS = 15000;

/// <summary>
/// Maximum service fees as a percentage for automatic swap operations
/// </summary>
public static decimal SWAP_MAX_SERVICE_FEES_PERCENT = 0.1m;

/// <summary>
/// Prepay amount in satoshis for automatic swap operations
/// </summary>
public static long SWAP_PREPAY_AMOUNT_SATS = 1337;

// Rebalance configuration
/// <summary>
/// Default max fee cap for an initial rebalance attempt, expressed as a percentage of
/// the rebalanced amount. ppm = pct × 10,000. 0.05 = 0.05% = 500 ppm.
/// </summary>
public static decimal REBALANCE_DEFAULT_MAX_FEE_PCT = 0.05m;

/// <summary>
/// Default max fee cap for retry attempts, expressed as a percentage of the rebalanced
/// amount. ppm = pct × 10,000. 0.075 = 0.075% = 750 ppm. More aggressive than the
/// initial cap to improve the chance of finding a route on retry.
/// </summary>
public static decimal REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT = 0.05m;

/// <summary>
/// Maximum number of attempts for a rebalance, including the first try.
/// </summary>
public static int REBALANCE_MAX_ATTEMPTS = 3;

/// <summary>
/// Delay before the first retry attempt, in seconds. Subsequent attempts use
/// REBALANCE_RETRY_BACKOFF_MULTIPLIER for exponential backoff.
/// </summary>
public static int REBALANCE_INITIAL_RETRY_DELAY_SECONDS = 60;

/// <summary>
/// Multiplier applied to the retry delay for each subsequent retry. With the default
/// 2.0 and an initial delay of 60s, retries fire at 60s, 120s, 240s, ...
/// </summary>
public static double REBALANCE_RETRY_BACKOFF_MULTIPLIER = 2.0;

/// <summary>
/// Smallest amount the probing binary search will halve down to before giving up.
/// </summary>
public static long REBALANCE_MIN_PROBE_AMOUNT_SATS = 10_000;

/// <summary>
/// Multiplier applied to the probe amount after each failure. Range: (0, 1) exclusive.
/// 0.5 (default) = halve the amount each iteration; 0.8 = next try is 20% smaller
/// (more iterations, finer granularity); 0.3 = next try is 70% smaller (fewer
/// iterations, gives up faster).
/// </summary>
public static double REBALANCE_PROBE_BACKOFF_RATIO = 0.8;

/// <summary>
/// Maximum number of QueryRoutes-returned routes the prober will try per amount level.
/// </summary>
public static int REBALANCE_MAX_PROBE_ROUTES_PER_AMOUNT = 6;

public const string IsFrozenTag = "frozen";
public const string IsManuallyFrozenTag = "manually_frozen";

Expand Down Expand Up @@ -334,13 +383,38 @@ static Constants()
// Swap configuration
var swapMaxMinerFees = Environment.GetEnvironmentVariable("SWAP_MAX_MINER_FEES_SATS");
SWAP_MAX_MINER_FEES_SATS = swapMaxMinerFees != null ? long.Parse(swapMaxMinerFees) : SWAP_MAX_MINER_FEES_SATS;

var swapMaxServiceFeesPercent = Environment.GetEnvironmentVariable("SWAP_MAX_SERVICE_FEES_PERCENT");
SWAP_MAX_SERVICE_FEES_PERCENT = swapMaxServiceFeesPercent != null ? decimal.Parse(swapMaxServiceFeesPercent, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture) : SWAP_MAX_SERVICE_FEES_PERCENT;

var swapPrepayAmount = Environment.GetEnvironmentVariable("SWAP_PREPAY_AMOUNT_SATS");
SWAP_PREPAY_AMOUNT_SATS = swapPrepayAmount != null ? long.Parse(swapPrepayAmount) : SWAP_PREPAY_AMOUNT_SATS;

// Rebalance configuration
var rebInitialPct = Environment.GetEnvironmentVariable("REBALANCE_DEFAULT_MAX_FEE_PCT");
if (rebInitialPct != null) REBALANCE_DEFAULT_MAX_FEE_PCT = decimal.Parse(rebInitialPct, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture);

var rebRetryPct = Environment.GetEnvironmentVariable("REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT");
if (rebRetryPct != null) REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT = decimal.Parse(rebRetryPct, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture);

var rebMaxAttempts = Environment.GetEnvironmentVariable("REBALANCE_MAX_ATTEMPTS");
if (rebMaxAttempts != null) REBALANCE_MAX_ATTEMPTS = int.Parse(rebMaxAttempts);

var rebInitialDelay = Environment.GetEnvironmentVariable("REBALANCE_INITIAL_RETRY_DELAY_SECONDS");
if (rebInitialDelay != null) REBALANCE_INITIAL_RETRY_DELAY_SECONDS = int.Parse(rebInitialDelay);

var rebBackoff = Environment.GetEnvironmentVariable("REBALANCE_RETRY_BACKOFF_MULTIPLIER");
if (rebBackoff != null) REBALANCE_RETRY_BACKOFF_MULTIPLIER = double.Parse(rebBackoff, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture);

var rebMinProbe = Environment.GetEnvironmentVariable("REBALANCE_MIN_PROBE_AMOUNT_SATS");
if (rebMinProbe != null) REBALANCE_MIN_PROBE_AMOUNT_SATS = long.Parse(rebMinProbe);

var rebProbeBackoff = Environment.GetEnvironmentVariable("REBALANCE_PROBE_BACKOFF_RATIO");
if (rebProbeBackoff != null) REBALANCE_PROBE_BACKOFF_RATIO = double.Parse(rebProbeBackoff, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture);

var rebMaxProbeRoutes = Environment.GetEnvironmentVariable("REBALANCE_MAX_PROBE_ROUTES_PER_AMOUNT");
if (rebMaxProbeRoutes != null) REBALANCE_MAX_PROBE_ROUTES_PER_AMOUNT = int.Parse(rebMaxProbeRoutes);

// DB Initialization
ALICE_PUBKEY = Environment.GetEnvironmentVariable("ALICE_PUBKEY") ?? ALICE_PUBKEY;
ALICE_HOST = Environment.GetEnvironmentVariable("ALICE_HOST") ?? ALICE_HOST;
Expand Down
109 changes: 109 additions & 0 deletions src/Proto/nodeguard.proto
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,21 @@ service NodeGuardService {
Adds tags to UTXOs under the treasury
*/
rpc AddTags(AddTagsRequest) returns (AddTagsResponse);

/*
Requests a circular self-payment rebalance on a managed node.
*/
rpc RequestRebalance(RequestRebalanceRequest) returns (RequestRebalanceResponse);

/*
Returns a single rebalance by id.
*/
rpc GetRebalance(GetRebalanceRequest) returns (GetRebalanceResponse);

/*
Returns a paginated list of rebalances with optional filters.
*/
rpc GetRebalances(GetRebalancesRequest) returns (GetRebalancesResponse);
}

message GetLiquidityRulesRequest {
Expand Down Expand Up @@ -444,3 +459,97 @@ message AddTagsRequest {

message AddTagsResponse {
}

enum REBALANCE_STATUS {
REBALANCE_PENDING = 0;
REBALANCE_PROBING = 1;
REBALANCE_IN_FLIGHT = 2;
REBALANCE_SUCCEEDED = 3;
REBALANCE_FAILED = 4;
REBALANCE_NO_ROUTE = 5;
REBALANCE_TIMEOUT = 6;
REBALANCE_INSUFFICIENT_BALANCE = 7;
REBALANCE_EXCEEDED_FEE_LIMIT = 8;
}

message RequestRebalanceRequest {
// Source node pubkey (33-byte hex)
string node_pubkey = 1;
// Amount in satoshis
int64 amount_sats = 2;
// Max fee as percentage of amount_sats. 0.05 means 0.05%.
// If unset, server defaults to REBALANCE_DEFAULT_MAX_FEE_PCT.
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
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.
optional string target_pubkey = 6;
// Pathfinding/payment timeout in seconds; 0 → server default (60s)
int32 timeout_seconds = 7;
// 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). If unset, falls back to
// Constants.REBALANCE_PROBE_BACKOFF_RATIO.
optional double probe_backoff_ratio = 8;
// Maximum number of attempts (including the first try). Must be >= 1.
// If unset, falls back to Constants.REBALANCE_MAX_ATTEMPTS
optional int32 max_attempts = 9;
}

message RequestRebalanceResponse {
int32 rebalance_id = 1;
REBALANCE_STATUS status = 2;
optional int64 fee_paid_sats = 3;
optional int64 effective_ppm = 4;
// Actual amount used; may be less than amount_sats if the prober reduced the amount.
optional int64 actual_amount_sats = 5;
int32 attempt_number = 6;
}

message GetRebalanceRequest {
int32 rebalance_id = 1;
}

message GetRebalanceResponse {
int32 rebalance_id = 1;
// Source node pubkey (33-byte hex)
string node_pubkey = 2;
REBALANCE_STATUS status = 3;
bool is_manual = 4;
int32 attempt_number = 5;
int64 requested_amount_sats = 6;
int64 amount_sats = 7;
// Max fee as percentage of amount_sats. 0.05 means 0.05%.
double max_fee_pct = 8;
optional double retry_max_fee_pct = 9;
optional int64 fee_paid_sats = 10;
optional int64 effective_ppm = 11;
optional uint64 source_chan_id = 12;
optional string target_pubkey = 13;
optional double probe_backoff_ratio = 14;
optional int32 max_attempts = 15;
int64 creation_datetime_unix = 16;
int64 update_datetime_unix = 17;
}

message GetRebalancesRequest {
// 1-based page number
int32 page_number = 1;
int32 page_size = 2;
optional REBALANCE_STATUS status = 3;
// Optional filter by source node pubkey (33-byte hex)
optional string node_pubkey = 4;
optional int32 source_channel_id = 5;
optional string user_id = 6;
optional bool is_manual = 7;
optional int64 from_unix = 8;
optional int64 to_unix = 9;
}

message GetRebalancesResponse {
repeated GetRebalanceResponse rebalances = 1;
int32 total_count = 2;
}
Loading