Skip to content

Commit 5b1177a

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 290bbde commit 5b1177a

17 files changed

Lines changed: 430 additions & 57 deletions

File tree

‎docs/api-guide.md‎

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ RPCs with no permission mapping return `UNIMPLEMENTED`, even for admin tokens.
103103
| `payments:claim` | Claim or fail held BOLT11 payments |
104104
| `payments:send` | Send BOLT11, BOLT12, spontaneous, unified, and refund payments; splice out |
105105
| `channels:read` | List channels |
106-
| `channels:manage` | Open, configure, cooperatively close, or splice funds into channels |
106+
| `channels:manage` | Open, configure, cooperatively close, splice funds into channels, or bump pending splice fees |
107107
| `channels:force_close` | Force-close channels |
108108
| `peers:read` | List peers |
109109
| `peers:manage` | Connect or disconnect peers |
@@ -235,15 +235,28 @@ a channel just-in-time when the invoice is paid.
235235

236236
### Channel Management
237237

238-
| RPC | Description |
239-
|-----------------------|------------------------------------------------------------------------|
240-
| `OpenChannel` | Open a new outbound channel (with optional push amount and fee config) |
241-
| `CloseChannel` | Cooperatively close a channel |
242-
| `ForceCloseChannel` | Force-close a channel unilaterally |
243-
| `SpliceIn` | Add on-chain funds to an existing channel |
244-
| `SpliceOut` | Remove funds from a channel back on-chain |
245-
| `UpdateChannelConfig` | Update forwarding fees and CLTV expiry delta |
246-
| `ListChannels` | List all channels with balances and configuration |
238+
| RPC | Description |
239+
| ----------------------- | ---------------------------------------------------------------------- |
240+
| `OpenChannel` | Open a new outbound channel (with optional push amount and fee config) |
241+
| `CloseChannel` | Cooperatively close a channel |
242+
| `ForceCloseChannel` | Force-close a channel unilaterally |
243+
| `SpliceIn` | Add on-chain funds to an existing channel |
244+
| `SpliceOut` | Remove funds from a channel back on-chain |
245+
| `BumpChannelFundingFee` | Raise the fee of a pending splice transaction |
246+
| `UpdateChannelConfig` | Update forwarding fees and CLTV expiry delta |
247+
| `ListChannels` | List all channels with balances and configuration |
248+
249+
> [!NOTE]
250+
> `BumpChannelFundingFee` supports pending splices only. It preserves the splice amount and
251+
> destination, and LDK Node selects the fee rate. General channel-opening fee bumps and
252+
> caller-selected splice fee rates are not supported.
253+
254+
Call it on the node that contributed to the splice, using the channel's `user_channel_id` and
255+
`counterparty_node_id`. A channel with no pending splice returns an error. An empty response means
256+
the fee bump has started; use [channel events](#event-streaming) to follow its progress.
257+
258+
The automatic increase can be below Bitcoin Core 29's minimum relay fee increase. Check that the
259+
replacement transaction reaches the mempool; a successful RPC does not guarantee relay or confirmation.
247260

248261
### Payment History
249262

‎e2e-tests/src/lib.rs‎

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

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

6466
let bitcoind = match std::env::var("BITCOIND_EXE") {
@@ -586,6 +588,17 @@ pub async fn wait_for_event(
586588
.expect("Timed out waiting for event")
587589
}
588590

591+
/// Wait for a negotiated splice and return its funding transaction ID.
592+
pub async fn splice_txid(events: &mut EventStream) -> String {
593+
let event = wait_for_event(events, |e| matches!(e, Event::SpliceNegotiated(_))).await;
594+
match event.event.unwrap() {
595+
Event::SpliceNegotiated(splice) => {
596+
splice.new_funding_txo.split(':').next().unwrap().to_string()
597+
},
598+
_ => unreachable!(),
599+
}
600+
}
601+
589602
/// Poll get_node_info until the server responds successfully.
590603
async fn wait_for_server_ready(handle: &LdkServerHandle, timeout: Duration) -> GetNodeInfoResponse {
591604
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/macaroons.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,14 @@ async fn test_macaroon_splice_permissions() {
144144
scoped_client.splice_out(Default::default()).await.unwrap_err().error_code,
145145
splice_out_error
146146
);
147+
assert_eq!(
148+
scoped_client
149+
.bump_channel_funding_fee(Default::default())
150+
.await
151+
.unwrap_err()
152+
.error_code,
153+
splice_in_error
154+
);
147155
}
148156
}
149157

‎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::{
@@ -291,3 +292,52 @@ async fn test_mcp_onchain_fee_bump() {
291292
}
292293
}
293294
}
295+
296+
#[tokio::test]
297+
async fn test_mcp_pending_splice_fee_bump() {
298+
let bitcoind = TestBitcoind::new();
299+
let server = LdkServerHandle::start(&bitcoind).await;
300+
let peer = LdkServerHandle::start(&bitcoind).await;
301+
let channel = setup_funded_channel(&bitcoind, &server, &peer, 100_000).await;
302+
let mut events = server.client().subscribe_events().await.unwrap();
303+
server
304+
.client()
305+
.splice_in(SpliceInRequest {
306+
user_channel_id: channel.clone(),
307+
counterparty_node_id: peer.node_id().into(),
308+
amount: Some(splice_in_request::Amount::SpliceAmountSats(50_000)),
309+
})
310+
.await
311+
.unwrap();
312+
let original = splice_txid(&mut events).await;
313+
let original_tx = wait_for_transaction(&bitcoind, &original).await;
314+
let funding_output = original_tx["vout"]
315+
.as_array()
316+
.unwrap()
317+
.iter()
318+
.find(|output| output["scriptPubKey"]["type"] == "witness_v0_scripthash")
319+
.unwrap();
320+
let mut mcp = McpHandle::start(&server);
321+
let response = mcp.call(
322+
1,
323+
"tools/call",
324+
json!({
325+
"name": "bump_channel_funding_fee",
326+
"arguments": {"user_channel_id": channel, "counterparty_node_id": peer.node_id()}
327+
}),
328+
);
329+
assert_ne!(response["result"]["isError"], true, "{response}");
330+
assert_eq!(tool_result_json(&response), json!({}));
331+
let replacement = splice_txid(&mut events).await;
332+
assert_ne!(original, replacement);
333+
let replacement_tx = wait_for_transaction(&bitcoind, &replacement).await;
334+
let replacement_output = replacement_tx["vout"]
335+
.as_array()
336+
.unwrap()
337+
.iter()
338+
.find(|output| output["scriptPubKey"] == funding_output["scriptPubKey"])
339+
.unwrap();
340+
assert_eq!(replacement_output["value"], funding_output["value"]);
341+
let mempool: Vec<String> = bitcoind.bitcoind.client.call("getrawmempool", &[]).unwrap();
342+
assert!(!mempool.contains(&original));
343+
}

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

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,12 @@ use ldk_server_client::ldk_server_grpc::api::{
3535
Bolt11SendUnderpayingRequest, Bolt11SendUnderpayingResponse, Bolt12CreatePayerProofRequest,
3636
Bolt12CreatePayerProofResponse, Bolt12ReceiveRefundRequest, Bolt12ReceiveRefundResponse,
3737
Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRefundRequest, Bolt12SendRefundResponse,
38-
Bolt12SendRequest, Bolt12SendResponse, CloseChannelRequest, CloseChannelResponse,
39-
ConnectPeerRequest, ConnectPeerResponse, CreateMacaroonRequest, CreateMacaroonResponse,
40-
DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse,
41-
DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest,
42-
ForceCloseChannelRequest, ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse,
38+
Bolt12SendRequest, Bolt12SendResponse, BumpChannelFundingFeeRequest,
39+
BumpChannelFundingFeeResponse, CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest,
40+
ConnectPeerResponse, CreateMacaroonRequest, CreateMacaroonResponse, DecodeInvoiceRequest,
41+
DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse, DisconnectPeerRequest,
42+
DisconnectPeerResponse, ExportPathfindingScoresRequest, ForceCloseChannelRequest,
43+
ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse,
4344
GetChannelForwardingStatsRequest, GetChannelForwardingStatsResponse,
4445
GetForwardedPaymentDetailsRequest, GetForwardedPaymentDetailsResponse,
4546
GetForwardedPaymentTrackingModeRequest, GetForwardedPaymentTrackingModeResponse,
@@ -571,6 +572,15 @@ enum Commands {
571572
)]
572573
address: Option<String>,
573574
},
575+
#[command(
576+
about = "Bump a pending splice fee. Does not support general channel-opening fee bumping. LDK Node selects the fee rate; callers cannot set it"
577+
)]
578+
BumpChannelFundingFee {
579+
#[arg(help = "The local user channel ID as a decimal u128 string")]
580+
user_channel_id: String,
581+
#[arg(help = "The hex-encoded public key of the channel's peer")]
582+
counterparty_node_id: String,
583+
},
574584
#[command(about = "Return a list of known channels")]
575585
ListChannels,
576586
#[command(about = "Retrieve list of all payments")]
@@ -1305,6 +1315,16 @@ async fn main() {
13051315
.await,
13061316
);
13071317
},
1318+
Commands::BumpChannelFundingFee { user_channel_id, counterparty_node_id } => {
1319+
handle_response_result::<_, BumpChannelFundingFeeResponse>(
1320+
client
1321+
.bump_channel_funding_fee(BumpChannelFundingFeeRequest {
1322+
user_channel_id,
1323+
counterparty_node_id,
1324+
})
1325+
.await,
1326+
);
1327+
},
13081328
Commands::ListChannels => {
13091329
handle_response_result::<_, ListChannelsResponse>(
13101330
client.list_channels(ListChannelsRequest {}).await,
@@ -1772,6 +1792,28 @@ mod tests {
17721792
}
17731793
}
17741794

1795+
#[test]
1796+
fn bump_channel_funding_fee_arguments() {
1797+
let cli = Cli::try_parse_from(["ldk-server-cli", "bump-channel-funding-fee", "42", "peer"])
1798+
.unwrap();
1799+
match cli.command {
1800+
Commands::BumpChannelFundingFee { user_channel_id, counterparty_node_id } => {
1801+
assert_eq!(user_channel_id, "42");
1802+
assert_eq!(counterparty_node_id, "peer");
1803+
},
1804+
_ => panic!("wrong command"),
1805+
}
1806+
assert!(Cli::try_parse_from([
1807+
"ldk-server-cli",
1808+
"bump-channel-funding-fee",
1809+
"42",
1810+
"peer",
1811+
"--fee-rate-sat-per-vb",
1812+
"10"
1813+
])
1814+
.is_err());
1815+
}
1816+
17751817
#[tokio::test]
17761818
async fn fetch_paginated_collects_multiple_pages() {
17771819
let response = fetch_paginated(

0 commit comments

Comments
 (0)