From ba342cab1a7a31eb7489ba6b9387954c3daec1ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Thu, 7 May 2026 13:33:48 +0200 Subject: [PATCH] Constants, Nodeguard rebalancing API + setup stack-info: PR: https://github.com/Elenpay/NodeGuard/pull/507, branch: Jossec101/stack/13 --- docker/bitcoin/setup.sh | 49 +++++++++++++---- src/Helpers/Constants.cs | 86 +++++++++++++++++++++++++++--- src/Proto/nodeguard.proto | 109 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 229 insertions(+), 15 deletions(-) diff --git a/docker/bitcoin/setup.sh b/docker/bitcoin/setup.sh index 34af8cb8..e05083f3 100755 --- a/docker/bitcoin/setup.sh +++ b/docker/bitcoin/setup.sh @@ -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 \ No newline at end of file +# 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 \ No newline at end of file diff --git a/src/Helpers/Constants.cs b/src/Helpers/Constants.cs index 1af5c803..1d21cedc 100644 --- a/src/Helpers/Constants.cs +++ b/src/Helpers/Constants.cs @@ -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 - + /// /// Minimum swap out size in BTC for automatic liquidity management (Swap Out). /// Can be configured via MINIMUM_SWAP_OUT_SIZE_BTC environment variable. /// public static decimal MINIMUM_SWAP_OUT_SIZE_BTC = 0.01m; - + /// /// Maximum swap out size in BTC for automatic liquidity management (Swap Out). /// Can be configured via MAXIMUM_SWAP_OUT_SIZE_BTC environment variable. @@ -132,17 +132,66 @@ public class Constants /// Maximum miner fees in satoshis for automatic swap operations /// public static long SWAP_MAX_MINER_FEES_SATS = 15000; - + /// /// Maximum service fees as a percentage for automatic swap operations /// public static decimal SWAP_MAX_SERVICE_FEES_PERCENT = 0.1m; - + /// /// Prepay amount in satoshis for automatic swap operations /// public static long SWAP_PREPAY_AMOUNT_SATS = 1337; + // Rebalance configuration + /// + /// 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. + /// + public static decimal REBALANCE_DEFAULT_MAX_FEE_PCT = 0.05m; + + /// + /// 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. + /// + public static decimal REBALANCE_DEFAULT_RETRY_MAX_FEE_PCT = 0.05m; + + /// + /// Maximum number of attempts for a rebalance, including the first try. + /// + public static int REBALANCE_MAX_ATTEMPTS = 3; + + /// + /// Delay before the first retry attempt, in seconds. Subsequent attempts use + /// REBALANCE_RETRY_BACKOFF_MULTIPLIER for exponential backoff. + /// + public static int REBALANCE_INITIAL_RETRY_DELAY_SECONDS = 60; + + /// + /// 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, ... + /// + public static double REBALANCE_RETRY_BACKOFF_MULTIPLIER = 2.0; + + /// + /// Smallest amount the probing binary search will halve down to before giving up. + /// + public static long REBALANCE_MIN_PROBE_AMOUNT_SATS = 10_000; + + /// + /// 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). + /// + public static double REBALANCE_PROBE_BACKOFF_RATIO = 0.8; + + /// + /// Maximum number of QueryRoutes-returned routes the prober will try per amount level. + /// + public static int REBALANCE_MAX_PROBE_ROUTES_PER_AMOUNT = 6; + public const string IsFrozenTag = "frozen"; public const string IsManuallyFrozenTag = "manually_frozen"; @@ -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; diff --git a/src/Proto/nodeguard.proto b/src/Proto/nodeguard.proto index a657c6a6..e26a3e46 100644 --- a/src/Proto/nodeguard.proto +++ b/src/Proto/nodeguard.proto @@ -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 { @@ -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; +}