Skip to content
Open
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
161 changes: 151 additions & 10 deletions lightning/src/ln/channelmanager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5582,6 +5582,29 @@ impl<
}
}

fn route_params_for_fixed_route(route: &mut Route) -> RouteParameters {
let params = route.route_params.clone().unwrap_or_else(|| {
let (payee_node_id, cltv_delta) = route
.paths
.first()
.and_then(|path| {
path.hops.last().map(|hop| (hop.pubkey, hop.cltv_expiry_delta as u32))
})
.unwrap_or_else(|| {
(PublicKey::from_slice(&[2; 33]).unwrap(), MIN_FINAL_CLTV_EXPIRY_DELTA as u32)
});
let dummy_payment_params = PaymentParameters::from_node_id(payee_node_id, cltv_delta);
RouteParameters::from_payment_params_and_value(
dummy_payment_params,
route.get_total_amount(),
)
});
if route.route_params.is_none() {
route.route_params = Some(params.clone());
}
params
}

/// Sends a payment along a given route. See [`Self::send_payment`] for more info.
///
/// LDK will not automatically retry this payment, though it may be manually re-sent after an
Expand All @@ -5593,25 +5616,51 @@ impl<
) -> Result<(), RetryableSendFailure> {
let best_block_height = self.best_block.read().unwrap().height;
let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self);
let route_params = route.route_params.clone().unwrap_or_else(|| {
// Create a dummy route params since they're a required parameter but unused in this case
let (payee_node_id, cltv_delta) = route.paths.first()
.and_then(|path| path.hops.last().map(|hop| (hop.pubkey, hop.cltv_expiry_delta as u32)))
.unwrap_or_else(|| (PublicKey::from_slice(&[2; 32]).unwrap(), MIN_FINAL_CLTV_EXPIRY_DELTA as u32));
let dummy_payment_params = PaymentParameters::from_node_id(payee_node_id, cltv_delta);
RouteParameters::from_payment_params_and_value(dummy_payment_params, route.get_total_amount())
});
if route.route_params.is_none() { route.route_params = Some(route_params.clone()); }
let route_params = Self::route_params_for_fixed_route(&mut route);
let router = FixedRouter::new(route);
let logger =
WithContext::for_payment(&self.logger, None, None, Some(payment_hash), payment_id);
self.pending_outbound_payments
.send_payment(payment_hash, recipient_onion, payment_id, Retry::Attempts(0),
route_params, &&router, self.list_usable_channels(), || self.compute_inflight_htlcs(),
route_params, &router, self.list_usable_channels(), || self.compute_inflight_htlcs(),
&self.entropy_source, &self.node_signer, best_block_height,
&self.pending_events, |args| self.send_payment_along_path(args), &logger)
}

/// Sends a spontaneous payment along a given route. See
/// [`Self::send_spontaneous_payment`] for more info.
///
/// 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?

&self, mut route: Route, payment_preimage: Option<PaymentPreimage>,
recipient_onion: RecipientOnionFields, payment_id: PaymentId,
) -> Result<PaymentHash, RetryableSendFailure> {
let best_block_height = self.best_block.read().unwrap().height;
let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self);
let route_params = Self::route_params_for_fixed_route(&mut route);
let router = FixedRouter::new(route);
let payment_hash = payment_preimage.map(|preimage| preimage.into());
let logger = WithContext::for_payment(&self.logger, None, None, payment_hash, payment_id);
self.pending_outbound_payments.send_spontaneous_payment(
payment_preimage,
recipient_onion,
payment_id,
Retry::Attempts(0),
route_params,
&router,
self.list_usable_channels(),
|| self.compute_inflight_htlcs(),
&self.entropy_source,
&self.node_signer,
best_block_height,
&self.pending_events,
|args| self.send_payment_along_path(args),
&logger,
)
}

/// Sends a payment to the route found using the provided [`RouteParameters`], retrying failed
/// payment paths based on the provided `Retry`.
///
Expand Down Expand Up @@ -6115,6 +6164,98 @@ impl<
)
}

/// Performs a circular rebalancing payment: funds exit our node over `outbound_channel_id`,
/// traverse the Lightning Network, and re-enter our node through `inbound_channel_id`.
///
/// This is a convenient helper for moving liquidity between two of our channels without
/// requiring a counterparty invoice. It is equivalent to constructing an appropriate circular
/// [`Route`] and sending a spontaneous (keysend) payment over it.
///
/// # How it works
///
/// A route hint directs the router through the `inbound_channel_id`'s counterparty to a dummy
/// payee, forced to start with `outbound_channel_id`. The dummy payee is then replaced with our
/// own node ID to close the loop. The route is sent as a spontaneous payment.
///
/// # Limitations
///
/// - Only single-path routing (no MPP support) is currently available.
/// - The payment is not recorded by the `Scorer`.
///
/// # Errors
///
/// Returns [`RetryableSendFailure::RouteNotFound`] if channel validation fails or no route can be
/// found. Payment-level errors (e.g. HTLC failures mid-flight) are reported asynchronously
/// via [`Event::PaymentFailed`].
///
/// [`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...

payment_id: PaymentId,
) -> Result<PaymentHash, RetryableSendFailure> {
if outbound_channel_id == inbound_channel_id {
return Err(RetryableSendFailure::RouteNotFound);
}

let usable_channels = self.list_usable_channels();
let out_chan = usable_channels
.iter()
.find(|c| c.channel_id == outbound_channel_id)
.ok_or(RetryableSendFailure::RouteNotFound)?;

let in_chan = usable_channels
.iter()
.find(|c| c.channel_id == inbound_channel_id)
.ok_or(RetryableSendFailure::RouteNotFound)?;

let our_node_id = self.get_our_node_id();
let forwarding_info = in_chan
.counterparty
.forwarding_info
.as_ref()
.ok_or(RetryableSendFailure::RouteNotFound)?;
let dummy_payee = PublicKey::from_slice(&[2; 33]).unwrap();
let route_hint =
crate::routing::router::RouteHint(vec![crate::routing::router::RouteHintHop {
src_node_id: in_chan.counterparty.node_id,
short_channel_id: in_chan
.get_inbound_payment_scid()
.ok_or(RetryableSendFailure::RouteNotFound)?,
fees: lightning_types::routing::RoutingFees {
base_msat: forwarding_info.fee_base_msat,
proportional_millionths: forwarding_info.fee_proportional_millionths,
},
cltv_expiry_delta: forwarding_info.cltv_expiry_delta,
htlc_minimum_msat: in_chan.inbound_htlc_minimum_msat,
htlc_maximum_msat: in_chan.inbound_htlc_maximum_msat,
}]);

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,

.with_route_hints(vec![route_hint])
.map_err(|_| RetryableSendFailure::RouteNotFound)?,
amount_msat,
);
Comment thread
Ferryx349 marked this conversation as resolved.

let first_hops: [&ChannelDetails; 1] = [out_chan];
let inflight_htlcs = self.compute_inflight_htlcs();
let mut route = self
.router
.find_route(&our_node_id, &route_params, Some(&first_hops), inflight_htlcs)
.map_err(|_| RetryableSendFailure::RouteNotFound)?;

for path in route.paths.iter_mut() {
if let Some(last) = path.hops.last_mut() {
last.pubkey = our_node_id;
}
Comment thread
Ferryx349 marked this conversation as resolved.
}

let preimage = PaymentPreimage(self.entropy_source.get_secure_random_bytes());
let onion = RecipientOnionFields::spontaneous_empty(amount_msat);
self.send_spontaneous_payment_with_route(route, Some(preimage), onion, payment_id)
}

/// Send a payment that is probing the given route for liquidity. We calculate the
/// [`PaymentHash`] of probes based on a static secret and a random [`PaymentId`], which allows
/// us to easily discern them from real payments.
Expand Down
124 changes: 124 additions & 0 deletions lightning/src/ln/payment_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5946,3 +5946,127 @@ fn bolt11_multi_node_mpp_with_retry() {
panic!("{payment_sent_b:?}");
}
}

#[test]
fn test_circular_payment_rebalance() {
let chanmon_cfgs = create_chanmon_cfgs(3);
let node_cfgs = create_node_cfgs(3, &chanmon_cfgs);
let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]);
let nodes = create_network(3, &node_cfgs, &node_chanmgrs);

let node_a_id = nodes[0].node.get_our_node_id();
let node_b_id = nodes[1].node.get_our_node_id();
let node_c_id = nodes[2].node.get_our_node_id();

let chan_1 = create_announced_chan_between_nodes(&nodes, 0, 1);
let _chan_2 = create_announced_chan_between_nodes(&nodes, 1, 2);
let chan_3 = create_announced_chan_between_nodes(&nodes, 2, 0);

let out_chan_id = chan_1.2;
let in_chan_id = chan_3.2;

let amount_msat = 10_000;

// Test 1: Same channel for both in/out
let same_chan_err = nodes[0].node.send_circular_payment(
out_chan_id,
out_chan_id,
amount_msat,
PaymentId([1; 32]),
);
assert_eq!(same_chan_err.unwrap_err(), RetryableSendFailure::RouteNotFound);

// Test 2: Channel not found
let fake_chan_id = ChannelId([99; 32]);
let missing_chan_err = nodes[0].node.send_circular_payment(
fake_chan_id,
in_chan_id,
amount_msat,
PaymentId([2; 32]),
);
assert_eq!(missing_chan_err.unwrap_err(), RetryableSendFailure::RouteNotFound);

let missing_chan_err2 = nodes[0].node.send_circular_payment(
out_chan_id,
fake_chan_id,
amount_msat,
PaymentId([3; 32]),
);
assert_eq!(missing_chan_err2.unwrap_err(), RetryableSendFailure::RouteNotFound);

// Test 3: Happy path
let payment_id = PaymentId([42; 32]);
let _hash = nodes[0]
.node
.send_circular_payment(out_chan_id, in_chan_id, amount_msat, payment_id)
.unwrap();
check_added_monitors(&nodes[0], 1);

// Route should be 0 -> 1 -> 2 -> 0.
let mut events = nodes[0].node.get_and_clear_pending_msg_events();
assert_eq!(events.len(), 1);

// Forward 0 -> 1
let node_1_msgs = remove_first_msg_event_to_node(&node_b_id, &mut events);
let send_event_1 = SendEvent::from_event(node_1_msgs);
nodes[1].node.handle_update_add_htlc(node_a_id, &send_event_1.msgs[0]);
do_commitment_signed_dance(&nodes[1], &nodes[0], &send_event_1.commitment_msg, false, true);

// Forward 1 -> 2
expect_and_process_pending_htlcs(&nodes[1], false);
check_added_monitors(&nodes[1], 1);
let mut events_1 = nodes[1].node.get_and_clear_pending_msg_events();
let node_2_msgs = remove_first_msg_event_to_node(&node_c_id, &mut events_1);
let send_event_2 = SendEvent::from_event(node_2_msgs);
nodes[2].node.handle_update_add_htlc(node_b_id, &send_event_2.msgs[0]);
do_commitment_signed_dance(&nodes[2], &nodes[1], &send_event_2.commitment_msg, false, true);

// Forward 2 -> 0
expect_and_process_pending_htlcs(&nodes[2], false);
check_added_monitors(&nodes[2], 1);
let mut events_2 = nodes[2].node.get_and_clear_pending_msg_events();
let node_0_msgs = remove_first_msg_event_to_node(&node_a_id, &mut events_2);
let send_event_3 = SendEvent::from_event(node_0_msgs);
nodes[0].node.handle_update_add_htlc(node_c_id, &send_event_3.msgs[0]);
do_commitment_signed_dance(&nodes[0], &nodes[2], &send_event_3.commitment_msg, false, true);

// Now node 0 should process it and claim it.
expect_and_process_pending_htlcs(&nodes[0], false);
let claim_events = nodes[0].node.get_and_clear_pending_events();
assert_eq!(claim_events.len(), 1);
let preimage = if let Event::PaymentClaimable {
purpose: PaymentPurpose::SpontaneousPayment(preimage),
..
} = claim_events[0]
{
preimage
} else {
panic!("Expected PaymentClaimable SpontaneousPayment");
};

let route: &[&[&Node]] = &[&[&nodes[1], &nodes[2], &nodes[0]]];
claim_payment_along_route(ClaimAlongRouteArgs::new(&nodes[0], route, preimage));
}

#[test]
fn test_circular_payment_no_route() {
let chanmon_cfgs = create_chanmon_cfgs(3);
let node_cfgs = create_node_cfgs(3, &chanmon_cfgs);
let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]);
let nodes = create_network(3, &node_cfgs, &node_chanmgrs);

let chan_1 = create_announced_chan_between_nodes(&nodes, 0, 1);
let chan_2 = create_announced_chan_between_nodes(&nodes, 0, 2);

let out_chan_id = chan_1.2;
let in_chan_id = chan_2.2;

let amount_msat = 10_000;
let no_route_err = nodes[0].node.send_circular_payment(
out_chan_id,
in_chan_id,
amount_msat,
PaymentId([5; 32]),
);
assert_eq!(no_route_err.unwrap_err(), RetryableSendFailure::RouteNotFound);
}
Loading