Skip to content

Commit d1cc2cc

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 5d8ec1b commit d1cc2cc

15 files changed

Lines changed: 402 additions & 38 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
@@ -60,6 +60,8 @@ impl TestBitcoind {
6060

6161
fn with_extra_args(extra_args: &[&str]) -> Self {
6262
let mut conf = corepc_node::Conf::default();
63+
// Match the pinned LDK Node splice fixtures' 0.1 sat/vB relay fee increase.
64+
conf.args.push("-incrementalrelayfee=0.00000100");
6365
conf.args.extend_from_slice(extra_args);
6466

6567
let bitcoind = match std::env::var("BITCOIND_EXE") {
@@ -589,6 +591,17 @@ pub async fn wait_for_event(
589591
.expect("Timed out waiting for event")
590592
}
591593

594+
/// Wait for a negotiated splice and return its funding transaction ID.
595+
pub async fn splice_txid(events: &mut EventStream) -> String {
596+
let event = wait_for_event(events, |e| matches!(e, Event::SpliceNegotiated(_))).await;
597+
match event.event.unwrap() {
598+
Event::SpliceNegotiated(splice) => {
599+
splice.new_funding_txo.split(':').next().unwrap().to_string()
600+
},
601+
_ => unreachable!(),
602+
}
603+
}
604+
592605
/// Poll get_node_info until the server responds successfully.
593606
async fn wait_for_server_ready(handle: &LdkServerHandle, timeout: Duration) -> GetNodeInfoResponse {
594607
let start = std::time::Instant::now();

‎e2e-tests/tests/e2e.rs‎

Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
1414
use e2e_tests::{
1515
assert_replacement, close_channel, find_available_port, list_payments, mine_and_sync,
1616
payment_for_tx, run_cli, run_cli_raw, run_cli_with_config, send_bolt11_payment,
17-
setup_funded_channel, wait_for_channels, wait_for_event, wait_for_gossip,
17+
setup_funded_channel, splice_txid, wait_for_channels, wait_for_event, wait_for_gossip,
1818
wait_for_onchain_balance, wait_for_settled_balance, wait_for_transaction,
1919
wait_for_usable_channel, wait_for_wallet_sync, LdkServerConfig, LdkServerHandle, TestBitcoind,
2020
TestConfigBuilder,
@@ -26,12 +26,12 @@ use ldk_node::lightning::ln::msgs::SocketAddress;
2626
use ldk_node::lightning::offers::offer::Offer;
2727
use ldk_node::lightning::offers::refund::Refund;
2828
use ldk_node::lightning_invoice::Bolt11Invoice;
29-
use ldk_server_client::error::LdkServerErrorCode::InvalidRequestError;
29+
use ldk_server_client::error::LdkServerErrorCode::{InvalidRequestError, LightningError};
3030
use ldk_server_client::ldk_server_grpc::api::{
3131
onchain_send_request, open_channel_request, Bolt11ClaimForIdRequest, Bolt11FailForIdRequest,
32-
Bolt11ReceiveRequest, Bolt11SendRequest, Bolt12ReceiveRequest,
32+
Bolt11ReceiveRequest, Bolt11SendRequest, Bolt12ReceiveRequest, BumpChannelFundingFeeRequest,
3333
GetChannelForwardingStatsRequest, GetForwardedPaymentDetailsRequest, GetPaymentDetailsRequest,
34-
ListChannelForwardingStatsRequest, ListChannelPairForwardingStatsRequest,
34+
ListChannelForwardingStatsRequest, ListChannelPairForwardingStatsRequest, ListChannelsRequest,
3535
ListForwardedPaymentsRequest, OnchainBumpFeeRequest, OnchainReceiveRequest, OnchainSendRequest,
3636
OpenChannelRequest,
3737
};
@@ -1522,6 +1522,132 @@ async fn test_cli_splice_out() {
15221522
assert!(address.starts_with("bcrt1"), "Expected regtest address, got: {}", address);
15231523
}
15241524

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

‎e2e-tests/tests/mcp.rs‎

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@ 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::bitcoin::Amount;
1919
use ldk_node::lightning::offers::refund::Refund;
2020
use ldk_server_client::ldk_server_grpc::api::{
21-
onchain_send_request, Bolt11ReceiveRequest, OnchainReceiveRequest, OnchainSendRequest,
21+
onchain_send_request, splice_in_request, Bolt11ReceiveRequest, OnchainReceiveRequest,
22+
OnchainSendRequest, SpliceInRequest,
2223
};
2324
use ldk_server_client::ldk_server_grpc::events::event_envelope::Event;
2425
use ldk_server_client::ldk_server_grpc::types::{
@@ -253,3 +254,52 @@ async fn test_mcp_onchain_fee_bump() {
253254
}
254255
}
255256
}
257+
258+
#[tokio::test]
259+
async fn test_mcp_pending_splice_fee_bump() {
260+
let bitcoind = TestBitcoind::new();
261+
let server = LdkServerHandle::start(&bitcoind).await;
262+
let peer = LdkServerHandle::start(&bitcoind).await;
263+
let channel = setup_funded_channel(&bitcoind, &server, &peer, 100_000).await;
264+
let mut events = server.client().subscribe_events().await.unwrap();
265+
server
266+
.client()
267+
.splice_in(SpliceInRequest {
268+
user_channel_id: channel.clone(),
269+
counterparty_node_id: peer.node_id().into(),
270+
amount: Some(splice_in_request::Amount::SpliceAmountSats(50_000)),
271+
})
272+
.await
273+
.unwrap();
274+
let original = splice_txid(&mut events).await;
275+
let original_tx = wait_for_transaction(&bitcoind, &original).await;
276+
let funding_output = original_tx["vout"]
277+
.as_array()
278+
.unwrap()
279+
.iter()
280+
.find(|output| output["scriptPubKey"]["type"] == "witness_v0_scripthash")
281+
.unwrap();
282+
let mut mcp = McpHandle::start(&server);
283+
let response = mcp.call(
284+
1,
285+
"tools/call",
286+
json!({
287+
"name": "bump_channel_funding_fee",
288+
"arguments": {"user_channel_id": channel, "counterparty_node_id": peer.node_id()}
289+
}),
290+
);
291+
assert_ne!(response["result"]["isError"], true, "{response}");
292+
assert_eq!(tool_result_json(&response), json!({}));
293+
let replacement = splice_txid(&mut events).await;
294+
assert_ne!(original, replacement);
295+
let replacement_tx = wait_for_transaction(&bitcoind, &replacement).await;
296+
let replacement_output = replacement_tx["vout"]
297+
.as_array()
298+
.unwrap()
299+
.iter()
300+
.find(|output| output["scriptPubKey"] == funding_output["scriptPubKey"])
301+
.unwrap();
302+
assert_eq!(replacement_output["value"], funding_output["value"]);
303+
let mempool: Vec<String> = bitcoind.bitcoind.client.call("getrawmempool", &[]).unwrap();
304+
assert!(!mempool.contains(&original));
305+
}

‎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)