Skip to content

Commit f460ecf

Browse files
committed
Add pending-splice funding fee bumping
Pending splices can remain unconfirmed when their funding fee is too low. Allow operators to raise that fee while preserving the splice amount and destination, so the channel balance change can complete. Support only pending splices with an automatic fee rate because the pinned LDK Node revision does not support channel-opening fee bumps or caller-selected splice fee rates. AI assistance: OpenAI Codex.
1 parent ce8be4f commit f460ecf

15 files changed

Lines changed: 410 additions & 43 deletions

File tree

‎docs/api-guide.md‎

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -151,15 +151,28 @@ a channel just-in-time when the invoice is paid.
151151

152152
### Channel Management
153153

154-
| RPC | Description |
155-
|-----------------------|------------------------------------------------------------------------|
156-
| `OpenChannel` | Open a new outbound channel (with optional push amount and fee config) |
157-
| `CloseChannel` | Cooperatively close a channel |
158-
| `ForceCloseChannel` | Force-close a channel unilaterally |
159-
| `SpliceIn` | Add on-chain funds to an existing channel |
160-
| `SpliceOut` | Remove funds from a channel back on-chain |
161-
| `UpdateChannelConfig` | Update forwarding fees and CLTV expiry delta |
162-
| `ListChannels` | List all channels with balances and configuration |
154+
| RPC | Description |
155+
| ----------------------- | ---------------------------------------------------------------------- |
156+
| `OpenChannel` | Open a new outbound channel (with optional push amount and fee config) |
157+
| `CloseChannel` | Cooperatively close a channel |
158+
| `ForceCloseChannel` | Force-close a channel unilaterally |
159+
| `SpliceIn` | Add on-chain funds to an existing channel |
160+
| `SpliceOut` | Remove funds from a channel back on-chain |
161+
| `BumpChannelFundingFee` | Raise the fee of a pending splice transaction |
162+
| `UpdateChannelConfig` | Update forwarding fees and CLTV expiry delta |
163+
| `ListChannels` | List all channels with balances and configuration |
164+
165+
> [!NOTE]
166+
> `BumpChannelFundingFee` supports pending splices only. It preserves the splice amount and
167+
> destination, and LDK Node selects the fee rate. General channel-opening fee bumps and
168+
> caller-selected splice fee rates are not supported.
169+
170+
Call it on the node that contributed to the splice, using the channel's `user_channel_id` and
171+
`counterparty_node_id`. A channel with no pending splice returns an error. An empty response means
172+
the fee bump has started; use [channel events](#event-streaming) to follow its progress.
173+
174+
The automatic increase can be below Bitcoin Core 29's minimum relay fee increase. Check that the
175+
replacement transaction reaches the mempool; a successful RPC does not guarantee relay or confirmation.
163176

164177
### Payment History
165178

‎e2e-tests/src/lib.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ impl TestBitcoind {
5252

5353
fn with_extra_args(extra_args: &[&str]) -> Self {
5454
let mut conf = corepc_node::Conf::default();
55+
// Match the pinned LDK Node splice fixtures' 0.1 sat/vB relay fee increase.
56+
conf.args.push("-incrementalrelayfee=0.00000100");
5557
conf.args.extend_from_slice(extra_args);
5658

5759
let bitcoind = match std::env::var("BITCOIND_EXE") {
@@ -547,6 +549,17 @@ pub async fn wait_for_event(
547549
.expect("Timed out waiting for event")
548550
}
549551

552+
/// Wait for a negotiated splice and return its funding transaction ID.
553+
pub async fn splice_txid(events: &mut EventStream) -> String {
554+
let event = wait_for_event(events, |e| matches!(e, Event::SpliceNegotiated(_))).await;
555+
match event.event.unwrap() {
556+
Event::SpliceNegotiated(splice) => {
557+
splice.new_funding_txo.split(':').next().unwrap().to_string()
558+
},
559+
_ => unreachable!(),
560+
}
561+
}
562+
550563
/// Poll get_node_info until the server responds successfully.
551564
async fn wait_for_server_ready(handle: &LdkServerHandle, timeout: Duration) -> GetNodeInfoResponse {
552565
let start = std::time::Instant::now();

‎e2e-tests/tests/e2e.rs‎

Lines changed: 138 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,24 +13,25 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
1313

1414
use e2e_tests::{
1515
assert_replacement, find_available_port, mine_and_sync, payment_for_tx, run_cli, run_cli_raw,
16-
run_cli_with_config, setup_funded_channel, wait_for_event, wait_for_onchain_balance,
17-
wait_for_transaction, wait_for_usable_channel, wait_for_wallet_sync, LdkServerConfig,
18-
LdkServerHandle, TestBitcoind, TestConfigBuilder,
16+
run_cli_with_config, setup_funded_channel, splice_txid, wait_for_event,
17+
wait_for_onchain_balance, wait_for_transaction, wait_for_usable_channel, wait_for_wallet_sync,
18+
LdkServerConfig, LdkServerHandle, TestBitcoind, TestConfigBuilder,
1919
};
2020
use hex_conservative::{DisplayHex, FromHex};
2121
use ldk_node::bitcoin::hashes::{sha256, Hash};
2222
use ldk_node::lightning::ln::msgs::SocketAddress;
2323
use ldk_node::lightning::offers::offer::Offer;
2424
use ldk_node::lightning::offers::refund::Refund;
2525
use ldk_node::lightning_invoice::Bolt11Invoice;
26-
use ldk_server_client::error::LdkServerErrorCode::InvalidRequestError;
26+
use ldk_server_client::error::LdkServerErrorCode::{InvalidRequestError, LightningError};
2727
use ldk_server_client::ldk_server_grpc::api::{
2828
onchain_send_request, open_channel_request, Bolt11ClaimForIdRequest, Bolt11FailForIdRequest,
29-
Bolt11ReceiveRequest, Bolt11SendRequest, Bolt12ReceiveRequest, GetBalancesRequest,
30-
GetChannelForwardingStatsRequest, GetForwardedPaymentDetailsRequest, GetPaymentDetailsRequest,
31-
ListChannelForwardingStatsRequest, ListChannelPairForwardingStatsRequest,
32-
ListForwardedPaymentsRequest, ListPaymentsRequest, OnchainBumpFeeRequest,
33-
OnchainReceiveRequest, OnchainSendRequest, OpenChannelRequest,
29+
Bolt11ReceiveRequest, Bolt11SendRequest, Bolt12ReceiveRequest, BumpChannelFundingFeeRequest,
30+
GetBalancesRequest, GetChannelForwardingStatsRequest, GetForwardedPaymentDetailsRequest,
31+
GetPaymentDetailsRequest, ListChannelForwardingStatsRequest,
32+
ListChannelPairForwardingStatsRequest, ListChannelsRequest, ListForwardedPaymentsRequest,
33+
ListPaymentsRequest, OnchainBumpFeeRequest, OnchainReceiveRequest, OnchainSendRequest,
34+
OpenChannelRequest,
3435
};
3536
use ldk_server_client::ldk_server_grpc::events::event_envelope::Event;
3637
use ldk_server_client::ldk_server_grpc::events::{
@@ -567,6 +568,7 @@ async fn test_onchain_fee_bump_invalid_requests_and_ineligible_payments() {
567568
assert_eq!(error.error_code, InvalidRequestError);
568569
assert_eq!(error.message, ldk_node::NodeError::InvalidFeeRate.to_string());
569570
}
571+
570572
let peer = LdkServerHandle::start(&bitcoind).await;
571573
let invoice = peer
572574
.client()
@@ -618,6 +620,7 @@ async fn test_onchain_fee_bump_invalid_requests_and_ineligible_payments() {
618620
.unwrap_err();
619621
assert_eq!(error.error_code, InvalidRequestError);
620622
}
623+
621624
#[tokio::test]
622625
async fn test_cli_connect_peer() {
623626
let bitcoind = TestBitcoind::new();
@@ -1541,6 +1544,132 @@ async fn test_cli_splice_out() {
15411544
assert!(address.starts_with("bcrt1"), "Expected regtest address, got: {}", address);
15421545
}
15431546

1547+
#[tokio::test]
1548+
async fn test_pending_splice_fee_bump_client_cli() {
1549+
let bitcoind = TestBitcoind::new();
1550+
let server = LdkServerHandle::start(&bitcoind).await;
1551+
let peer = LdkServerHandle::start(&bitcoind).await;
1552+
let channel = setup_funded_channel(&bitcoind, &server, &peer, 100_000).await;
1553+
let request = BumpChannelFundingFeeRequest {
1554+
user_channel_id: channel.clone(),
1555+
counterparty_node_id: peer.node_id().into(),
1556+
};
1557+
let error = server.client().bump_channel_funding_fee(request.clone()).await.unwrap_err();
1558+
assert_eq!(error.error_code, LightningError);
1559+
let mut wrong_peer = request.clone();
1560+
wrong_peer.counterparty_node_id = server.node_id().into();
1561+
assert_eq!(
1562+
server.client().bump_channel_funding_fee(wrong_peer).await.unwrap_err().error_code,
1563+
LightningError
1564+
);
1565+
1566+
let mut events = server.client().subscribe_events().await.unwrap();
1567+
// Use the same funded channel and splice-in operation as the existing splice fixtures.
1568+
run_cli(&server, &["splice-in", &channel, peer.node_id(), "50000sat"]);
1569+
let original = splice_txid(&mut events).await;
1570+
let original_tx = wait_for_transaction(&bitcoind, &original).await;
1571+
let funding_output = original_tx["vout"]
1572+
.as_array()
1573+
.unwrap()
1574+
.iter()
1575+
.find(|output| output["scriptPubKey"]["type"] == "witness_v0_scripthash")
1576+
.unwrap();
1577+
let expected_channel_value =
1578+
ldk_node::bitcoin::Amount::from_btc(funding_output["value"].as_f64().unwrap())
1579+
.unwrap()
1580+
.to_sat();
1581+
assert!(expected_channel_value >= 150_000);
1582+
1583+
let funding = payment_for_tx(&server, &original).await;
1584+
let error = server
1585+
.client()
1586+
.onchain_bump_fee(OnchainBumpFeeRequest {
1587+
payment_id: funding.payment_id,
1588+
fee_rate_sat_per_vb: Some(10),
1589+
})
1590+
.await
1591+
.unwrap_err();
1592+
assert_eq!(error.error_code, InvalidRequestError);
1593+
1594+
server.client().bump_channel_funding_fee(request.clone()).await.unwrap();
1595+
let replacement = splice_txid(&mut events).await;
1596+
assert_ne!(original, replacement);
1597+
wait_for_transaction(&bitcoind, &replacement).await;
1598+
let cli = run_cli(&server, &["bump-channel-funding-fee", &channel, peer.node_id()]);
1599+
assert_eq!(cli, json!({}));
1600+
let cli_txid = splice_txid(&mut events).await;
1601+
assert_ne!(replacement, cli_txid);
1602+
let replacement_tx = wait_for_transaction(&bitcoind, &cli_txid).await;
1603+
let replacement_output = replacement_tx["vout"]
1604+
.as_array()
1605+
.unwrap()
1606+
.iter()
1607+
.find(|output| output["scriptPubKey"] == funding_output["scriptPubKey"])
1608+
.unwrap();
1609+
assert_eq!(replacement_output["value"], funding_output["value"]);
1610+
let mempool: Vec<String> = bitcoind.bitcoind.client.call("getrawmempool", &[]).unwrap();
1611+
for old in [&original, &replacement] {
1612+
assert!(!mempool.contains(old));
1613+
}
1614+
mine_and_sync(&bitcoind, &[&server, &peer], 6).await;
1615+
tokio::time::timeout(Duration::from_secs(30), async {
1616+
loop {
1617+
let channels = server.client().list_channels(ListChannelsRequest {}).await.unwrap();
1618+
if channels.channels.iter().any(|c| {
1619+
c.user_channel_id == channel
1620+
&& c.channel_value_sats == expected_channel_value
1621+
&& c.is_usable
1622+
}) {
1623+
break;
1624+
}
1625+
tokio::time::sleep(Duration::from_millis(100)).await;
1626+
}
1627+
})
1628+
.await
1629+
.expect("replacement splice did not confirm with the original amount");
1630+
assert_eq!(
1631+
server.client().bump_channel_funding_fee(request).await.unwrap_err().error_code,
1632+
LightningError
1633+
);
1634+
}
1635+
1636+
#[tokio::test]
1637+
async fn test_pending_splice_fee_bump_invalid_requests() {
1638+
let bitcoind = TestBitcoind::new();
1639+
let server = LdkServerHandle::start(&bitcoind).await;
1640+
for id in ["", "-1", "xyz", "340282366920938463463374607431768211456"] {
1641+
let error = server
1642+
.client()
1643+
.bump_channel_funding_fee(BumpChannelFundingFeeRequest {
1644+
user_channel_id: id.into(),
1645+
counterparty_node_id: server.node_id().into(),
1646+
})
1647+
.await
1648+
.unwrap_err();
1649+
assert_eq!(error.error_code, InvalidRequestError);
1650+
}
1651+
for peer in ["", "invalid", &"00".repeat(33)] {
1652+
let error = server
1653+
.client()
1654+
.bump_channel_funding_fee(BumpChannelFundingFeeRequest {
1655+
user_channel_id: "1".into(),
1656+
counterparty_node_id: peer.into(),
1657+
})
1658+
.await
1659+
.unwrap_err();
1660+
assert_eq!(error.error_code, InvalidRequestError);
1661+
}
1662+
let error = server
1663+
.client()
1664+
.bump_channel_funding_fee(BumpChannelFundingFeeRequest {
1665+
user_channel_id: u128::MAX.to_string(),
1666+
counterparty_node_id: server.node_id().into(),
1667+
})
1668+
.await
1669+
.unwrap_err();
1670+
assert_eq!(error.error_code, LightningError);
1671+
}
1672+
15441673
#[tokio::test]
15451674
async fn test_cli_graph_list_channels_empty() {
15461675
let bitcoind = TestBitcoind::new();

‎e2e-tests/tests/mcp.rs‎

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@ use std::str::FromStr;
1111
use std::time::Duration;
1212

1313
use e2e_tests::{
14-
assert_replacement, mine_and_sync, payment_for_tx, setup_funded_channel, wait_for_event,
15-
wait_for_onchain_balance, wait_for_transaction, wait_for_wallet_sync, LdkServerHandle,
16-
McpHandle, TestBitcoind,
14+
assert_replacement, mine_and_sync, payment_for_tx, setup_funded_channel, splice_txid,
15+
wait_for_event, wait_for_onchain_balance, wait_for_transaction, wait_for_wallet_sync,
16+
LdkServerHandle, McpHandle, TestBitcoind,
1717
};
1818
use ldk_node::lightning::offers::refund::Refund;
1919
use ldk_server_client::ldk_server_grpc::api::{
20-
onchain_send_request, Bolt11ReceiveRequest, OnchainReceiveRequest, OnchainSendRequest,
20+
onchain_send_request, splice_in_request, Bolt11ReceiveRequest, OnchainReceiveRequest,
21+
OnchainSendRequest, SpliceInRequest,
2122
};
2223
use ldk_server_client::ldk_server_grpc::events::event_envelope::Event;
2324
use ldk_server_client::ldk_server_grpc::types::{
@@ -251,3 +252,52 @@ async fn test_mcp_onchain_fee_bump() {
251252
}
252253
}
253254
}
255+
256+
#[tokio::test]
257+
async fn test_mcp_pending_splice_fee_bump() {
258+
let bitcoind = TestBitcoind::new();
259+
let server = LdkServerHandle::start(&bitcoind).await;
260+
let peer = LdkServerHandle::start(&bitcoind).await;
261+
let channel = setup_funded_channel(&bitcoind, &server, &peer, 100_000).await;
262+
let mut events = server.client().subscribe_events().await.unwrap();
263+
server
264+
.client()
265+
.splice_in(SpliceInRequest {
266+
user_channel_id: channel.clone(),
267+
counterparty_node_id: peer.node_id().into(),
268+
amount: Some(splice_in_request::Amount::SpliceAmountSats(50_000)),
269+
})
270+
.await
271+
.unwrap();
272+
let original = splice_txid(&mut events).await;
273+
let original_tx = wait_for_transaction(&bitcoind, &original).await;
274+
let funding_output = original_tx["vout"]
275+
.as_array()
276+
.unwrap()
277+
.iter()
278+
.find(|output| output["scriptPubKey"]["type"] == "witness_v0_scripthash")
279+
.unwrap();
280+
let mut mcp = McpHandle::start(&server);
281+
let response = mcp.call(
282+
1,
283+
"tools/call",
284+
json!({
285+
"name": "bump_channel_funding_fee",
286+
"arguments": {"user_channel_id": channel, "counterparty_node_id": peer.node_id()}
287+
}),
288+
);
289+
assert_ne!(response["result"]["isError"], true, "{response}");
290+
assert_eq!(tool_result_json(&response), json!({}));
291+
let replacement = splice_txid(&mut events).await;
292+
assert_ne!(original, replacement);
293+
let replacement_tx = wait_for_transaction(&bitcoind, &replacement).await;
294+
let replacement_output = replacement_tx["vout"]
295+
.as_array()
296+
.unwrap()
297+
.iter()
298+
.find(|output| output["scriptPubKey"] == funding_output["scriptPubKey"])
299+
.unwrap();
300+
assert_eq!(replacement_output["value"], funding_output["value"]);
301+
let mempool: Vec<String> = bitcoind.bitcoind.client.call("getrawmempool", &[]).unwrap();
302+
assert!(!mempool.contains(&original));
303+
}

‎ldk-server-cli/src/main.rs‎

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,10 @@ use ldk_server_client::ldk_server_grpc::api::{
3232
Bolt11SendUnderpayingRequest, Bolt11SendUnderpayingResponse, Bolt12CreatePayerProofRequest,
3333
Bolt12CreatePayerProofResponse, Bolt12ReceiveRefundRequest, Bolt12ReceiveRefundResponse,
3434
Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRefundRequest, Bolt12SendRefundResponse,
35-
Bolt12SendRequest, Bolt12SendResponse, CloseChannelRequest, CloseChannelResponse,
36-
ConnectPeerRequest, ConnectPeerResponse, DecodeInvoiceRequest, DecodeInvoiceResponse,
37-
DecodeOfferRequest, DecodeOfferResponse, DisconnectPeerRequest, DisconnectPeerResponse,
35+
Bolt12SendRequest, Bolt12SendResponse, BumpChannelFundingFeeRequest,
36+
BumpChannelFundingFeeResponse, CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest,
37+
ConnectPeerResponse, DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest,
38+
DecodeOfferResponse, DisconnectPeerRequest, DisconnectPeerResponse,
3839
ExportPathfindingScoresRequest, ForceCloseChannelRequest, ForceCloseChannelResponse,
3940
GetBalancesRequest, GetBalancesResponse, GetChannelForwardingStatsRequest,
4041
GetChannelForwardingStatsResponse, GetForwardedPaymentDetailsRequest,
@@ -546,6 +547,15 @@ enum Commands {
546547
)]
547548
address: Option<String>,
548549
},
550+
#[command(
551+
about = "Bump a pending splice fee. Does not support general channel-opening fee bumping. LDK Node selects the fee rate; callers cannot set it"
552+
)]
553+
BumpChannelFundingFee {
554+
#[arg(help = "The local user channel ID as a decimal u128 string")]
555+
user_channel_id: String,
556+
#[arg(help = "The hex-encoded public key of the channel's peer")]
557+
counterparty_node_id: String,
558+
},
549559
#[command(about = "Return a list of known channels")]
550560
ListChannels,
551561
#[command(about = "Retrieve list of all payments")]
@@ -1225,6 +1235,16 @@ async fn main() {
12251235
.await,
12261236
);
12271237
},
1238+
Commands::BumpChannelFundingFee { user_channel_id, counterparty_node_id } => {
1239+
handle_response_result::<_, BumpChannelFundingFeeResponse>(
1240+
client
1241+
.bump_channel_funding_fee(BumpChannelFundingFeeRequest {
1242+
user_channel_id,
1243+
counterparty_node_id,
1244+
})
1245+
.await,
1246+
);
1247+
},
12281248
Commands::ListChannels => {
12291249
handle_response_result::<_, ListChannelsResponse>(
12301250
client.list_channels(ListChannelsRequest {}).await,
@@ -1621,6 +1641,28 @@ mod tests {
16211641
}
16221642
}
16231643

1644+
#[test]
1645+
fn bump_channel_funding_fee_arguments() {
1646+
let cli = Cli::try_parse_from(["ldk-server-cli", "bump-channel-funding-fee", "42", "peer"])
1647+
.unwrap();
1648+
match cli.command {
1649+
Commands::BumpChannelFundingFee { user_channel_id, counterparty_node_id } => {
1650+
assert_eq!(user_channel_id, "42");
1651+
assert_eq!(counterparty_node_id, "peer");
1652+
},
1653+
_ => panic!("wrong command"),
1654+
}
1655+
assert!(Cli::try_parse_from([
1656+
"ldk-server-cli",
1657+
"bump-channel-funding-fee",
1658+
"42",
1659+
"peer",
1660+
"--fee-rate-sat-per-vb",
1661+
"10"
1662+
])
1663+
.is_err());
1664+
}
1665+
16241666
#[tokio::test]
16251667
async fn fetch_paginated_collects_multiple_pages() {
16261668
let response = fetch_paginated(

0 commit comments

Comments
 (0)