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;
+}