Skip to content

Add helpers for circular rebalancing#4616

Open
Ferryx349 wants to merge 1 commit into
lightningdevkit:mainfrom
Ferryx349:cir-reb
Open

Add helpers for circular rebalancing#4616
Ferryx349 wants to merge 1 commit into
lightningdevkit:mainfrom
Ferryx349:cir-reb

Conversation

@Ferryx349
Copy link
Copy Markdown

@Ferryx349 Ferryx349 commented May 17, 2026

Fixes- #3791
This PR adds two new methods to ChannelManager:

send_circular_payment

A helper for circular channel rebalancing. It:

  1. Validates that two provided channel IDs are distinct and usable.
  2. Calls find_route targeting the inbound channel's counterparty, with first_hops restricted to only the outbound channel to force the router to use it as the first hop.
  3. Manually appends a final RouteHop back to our own node over the inbound channel's SCID, with fee_msat set to the full payment amount as required for the last hop and the preceding hop's fee_msat adjusted to counterparty's forwarding fee.
  4. Sends result as a spontaneous payment via send_spontaneous_payment_with_route.

The existing guard in pay_route_internal already permits our node as the last hop in a path ("simple rebalance loop to us"), so no changes to the payment sending internals were needed.

Tests

  1. test_circular_payment_rebalance: covers the happy path (0→1→2→0), same-channel error, and channel-not-found error.
  2. test_circular_payment_no_route: verifies RouteNotFound is returned when no path exists between the two counterparties.

This is my first contribution to LDK. I tried to follow the patterns established by send_payment_with_route and send_spontaneous_preflight_probes as closely as possible. Happy to revise anything that doesn't match project conventions.

CC: @tnull

@ldk-reviews-bot
Copy link
Copy Markdown

ldk-reviews-bot commented May 17, 2026

👋 I see @wpaulino was un-assigned.
If you'd like another reviewer assignment, please click here.

@ldk-reviews-bot ldk-reviews-bot requested a review from wpaulino May 17, 2026 03:32
Comment thread lightning/src/ln/channelmanager.rs
Comment thread lightning/src/ln/channelmanager.rs Outdated
Comment thread lightning/src/ln/channelmanager.rs Outdated
Comment thread lightning/src/ln/channelmanager.rs
@ldk-claude-review-bot
Copy link
Copy Markdown
Collaborator

ldk-claude-review-bot commented May 17, 2026

Review Summary

Most prior review issues have been addressed in this revision:

  • Route hint approach: The code now uses RouteHint with the inbound counterparty's forwarding info, letting the router handle CLTV/fee propagation correctly. This resolves the prior cltv_expiry_delta and fee calculation issues.
  • forwarding_info None: Now returns RouteNotFound early (line 6217).
  • HTLC limits: Now populated from in_chan.inbound_htlc_minimum_msat / inbound_htlc_maximum_msat (lines 6230-6231).
  • [2; 32] bug: Fixed to [2; 33] (line 5594).
  • DRY: route_params_for_fixed_route helper extracted (line 5585).
  • &&router to &router: Cleaned up (line 6625).
  • Doc text: Updated to match route-hint-based implementation (lines 6174-6178).
  • send_spontaneous_payment_with_route doc: Now references send_spontaneous_payment and mentions no auto-retry (lines 5630-5635).

Still Open from Prior Review

  • lightning/src/ln/channelmanager.rs:6235max_path_count defaults to 10 but the doc says "no MPP support". Should be set to 1 for defense-in-depth, since spontaneous_empty has no payment_secret and MPP aggregation would fail on the receiver side. (Already posted as inline comment in prior review pass.)

No New Issues Found

After thorough analysis of the entire diff — including the route construction, CLTV/fee propagation through the route hint, onion payload construction, receiver-side keysend processing, FixedRouter/validate_found_route interaction, and test correctness — no new issues were identified beyond what was already flagged.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 17, 2026

Codecov Report

❌ Patch coverage is 93.26923% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.58%. Comparing base (65e8cc8) to head (420158b).
⚠️ Report is 86 commits behind head on main.

Files with missing lines Patch % Lines
lightning/src/ln/channelmanager.rs 93.26% 3 Missing and 4 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4616      +/-   ##
==========================================
+ Coverage   86.15%   86.58%   +0.42%     
==========================================
  Files         158      159       +1     
  Lines      109323   110515    +1192     
  Branches   109323   110515    +1192     
==========================================
+ Hits        94189    95686    +1497     
+ Misses      12518    12291     -227     
+ Partials     2616     2538      -78     
Flag Coverage Δ
fuzzing-fake-hashes 6.56% <0.00%> (?)
fuzzing-real-hashes 23.11% <12.50%> (?)
tests 86.22% <93.26%> (+0.06%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Comment thread lightning/src/ln/channelmanager.rs Outdated
Comment thread lightning/src/ln/channelmanager.rs Outdated
Comment thread lightning/src/ln/channelmanager.rs Outdated
Comment thread lightning/src/ln/channelmanager.rs Outdated
Comment on lines +6218 to +6219
let forwarding_fee = forwarding_info.fee_base_msat as u64
+ (forwarding_info.fee_proportional_millionths as u64 * amount_msat) / 1000000;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit/correctness: The multiplication fee_proportional_millionths as u64 * amount_msat can overflow u64 for extreme (but valid) values of the two operands. The channel forwarding code at channel.rs:11335 uses checked_mul for the same fee calculation:

let fee = amt_to_forward.checked_mul(config.forwarding_fee_proportional_millionths as u64)
    .and_then(|prop_fee| (prop_fee / 1000000).checked_add(config.forwarding_fee_base_msat as u64));

Consider using checked arithmetic here to be consistent and avoid a wrapping fee (which would cause a confusing async payment failure):

Suggested change
let forwarding_fee = forwarding_info.fee_base_msat as u64
+ (forwarding_info.fee_proportional_millionths as u64 * amount_msat) / 1000000;
let forwarding_fee = (forwarding_info.fee_proportional_millionths as u64)
.checked_mul(amount_msat)
.and_then(|prop_fee| (prop_fee / 1_000_000)
.checked_add(forwarding_info.fee_base_msat as u64))
.ok_or(RetryableSendFailure::RouteNotFound)?;

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 1st Reminder

Hey @wpaulino! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@Ferryx349 Ferryx349 changed the title Add 'send_circular_payment' helper and 'send_spontaneous_payment_with… Add helpers for circular rebalancing May 19, 2026
@TheBlueMatt TheBlueMatt removed the request for review from wpaulino May 19, 2026 15:49
/// [`Route`]: crate::routing::router::Route
/// [`Event::PaymentFailed`]: crate::events::Event::PaymentFailed
pub fn send_circular_payment(
&self, outbound_channel_id: ChannelId, inbound_channel_id: ChannelId, amount_msat: u64,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the inbound/outbound edges be a list?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept them as single channels since the implementation is single-path only for now. Happy to change outbound to a list, straightforward with first_hops, but inbound as a list needs more thought on distributing paths across channels...

Comment thread lightning/src/ln/channelmanager.rs Outdated
prev_last.fee_msat = forwarding_fee;
prev_last.cltv_expiry_delta = cltv_expiry_delta;
}
path.hops.push(last_hop.clone());
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be marginally simpler to do a find_route to a dummy recipient pubkey with the last-hop pre-filled in the route params so we don't have to manually build a last-hop and push.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've refactored this in latest commit

Comment thread lightning/src/ln/channelmanager.rs Outdated
};
for path in route.paths.iter_mut() {
if let Some(prev_last) = path.hops.last_mut() {
prev_last.fee_msat = forwarding_fee;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may break the path cause we have to compute an additional proportional fee on previous hops.

/// LDK will not automatically retry this payment, though it may be manually
/// re-sent after an
/// [`Event::PaymentFailed`] is generated.
pub fn send_spontaneous_payment_with_route(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding this method is fine, but I'm not actually sure we want the rebalance "payment" to be a spontaneous payment - it maybe should be its own class of payment and generate a different set of events (or, maybe, if we don't want yet more events, at least result in a flag in the payment claimed/sent events being set that marks it as a rebalance payment).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense, can add a variant to make it distinguishable, or a boolean flag to existing events, which do you prefer?

@Ferryx349 Ferryx349 force-pushed the cir-reb branch 2 times, most recently from db9e7b7 to a02b75f Compare May 20, 2026 07:28
Comment thread lightning/src/ln/channelmanager.rs Outdated
Comment thread lightning/src/ln/channelmanager.rs Outdated
…_route' to ChannelManager

Signed-off-by: ABHAY PANDEY <pandeyabhay967@gmail.com>
}]);

let route_params = RouteParameters::from_payment_params_and_value(
PaymentParameters::from_node_id(dummy_payee, MIN_FINAL_CLTV_EXPIRY_DELTA as u32)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc at line 6182 states "Only single-path routing (no MPP support) is currently available", but PaymentParameters::from_node_id defaults max_path_count to DEFAULT_MAX_PATH_COUNT (10). If the router finds multiple independent routes between the outbound and inbound counterparties, it could split the payment into multiple paths. Since spontaneous_empty has no payment_secret, MPP aggregation on the receiver side won't work — each HTLC arrives as a separate spontaneous payment.

This is unlikely in practice (single first_hop + single route hint constrains the router), but for defense-in-depth the code should enforce what the doc promises:

Suggested change
PaymentParameters::from_node_id(dummy_payee, MIN_FINAL_CLTV_EXPIRY_DELTA as u32)
let mut payment_params = PaymentParameters::from_node_id(dummy_payee, MIN_FINAL_CLTV_EXPIRY_DELTA as u32)
.with_route_hints(vec![route_hint])
.map_err(|_| RetryableSendFailure::RouteNotFound)?;
payment_params.max_path_count = 1;
let route_params = RouteParameters::from_payment_params_and_value(
payment_params,

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants