Skip to content

Commit c005623

Browse files
jkczyzclaude
andcommitted
Test splice resumption across restarts and document automatic retry
Add integration coverage for resuming a dropped splice: splice_resumed_after_restart initiates a splice-out while disconnected, restarts the node before anything is negotiated, and asserts the reconciler resumes and completes the splice -- and that a second restart does not resubmit the now-locked splice. splice_rbf_resumed_after_restart does the same for a fee bump. Document on splice_in, splice_out, and bump_channel_funding_fee that the splice is retried automatically across restarts until it completes or is given up on. Generated with assistance from Claude Code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 71d689d commit c005623

2 files changed

Lines changed: 217 additions & 3 deletions

File tree

src/lib.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1822,6 +1822,10 @@ impl Node {
18221822
/// it. Once negotiation with the counterparty is complete, the channel remains operational
18231823
/// while waiting for a new funding transaction to confirm.
18241824
///
1825+
/// The splice is retried automatically, including across restarts, until it either completes or
1826+
/// fails for a reason retrying cannot address, at which point [`Event::SpliceNegotiationFailed`]
1827+
/// is emitted.
1828+
///
18251829
/// # Experimental API
18261830
///
18271831
/// This API is experimental. Currently, a splice-in will be marked as an outbound payment, but
@@ -1846,6 +1850,10 @@ impl Node {
18461850
/// it. Once negotiation with the counterparty is complete, the channel remains operational
18471851
/// while waiting for a new funding transaction to confirm.
18481852
///
1853+
/// The splice is retried automatically, including across restarts, until it either completes or
1854+
/// fails for a reason retrying cannot address, at which point [`Event::SpliceNegotiationFailed`]
1855+
/// is emitted.
1856+
///
18491857
/// # Experimental API
18501858
///
18511859
/// This API is experimental. Currently, a splice-in will be marked as an outbound payment, but
@@ -1862,6 +1870,10 @@ impl Node {
18621870
/// it. Once negotiation with the counterparty is complete, the channel remains operational
18631871
/// while waiting for a new funding transaction to confirm.
18641872
///
1873+
/// The splice is retried automatically, including across restarts, until it either completes or
1874+
/// fails for a reason retrying cannot address, at which point [`Event::SpliceNegotiationFailed`]
1875+
/// is emitted.
1876+
///
18651877
/// # Experimental API
18661878
///
18671879
/// This API is experimental. Currently, a splice-out will be marked as an inbound payment if
@@ -1959,6 +1971,10 @@ impl Node {
19591971
/// Fee-bumps the pending splice on a channel by replacing its in-flight funding transaction
19601972
/// (RBF). The splice's amount and destination are preserved; only the fee rate is raised.
19611973
/// Errors if the channel has no pending splice to bump.
1974+
///
1975+
/// The fee bump is retried automatically, including across restarts, until it either completes
1976+
/// or fails for a reason retrying cannot address, at which point
1977+
/// [`Event::SpliceNegotiationFailed`] is emitted.
19621978
pub fn bump_channel_funding_fee(
19631979
&self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey,
19641980
) -> Result<(), Error> {

tests/integration_tests_rust.rs

Lines changed: 201 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1734,7 +1734,9 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) {
17341734
},
17351735
}
17361736
assert_eq!(payment.status, PaymentStatus::Pending);
1737-
// Only one Onchain Pending payment for this splice attempt (not one per candidate).
1737+
// Only one Onchain Pending payment for this splice attempt (not one per candidate). This also
1738+
// guards the intent-clobber fix: had the sync above cleared this splice's live intent, the
1739+
// bump would not have found it and would have minted a second record under a fresh PaymentId.
17381740
let splice_payments = node_b.list_payments_with_filter(|p| {
17391741
p.direction == PaymentDirection::Outbound
17401742
&& matches!(p.kind, PaymentKind::Onchain { .. })
@@ -2034,8 +2036,9 @@ async fn rbf_splice_payment_reverts_after_deep_reorg() {
20342036

20352037
let rbf_payment_id = PaymentId(rbf_txo.txid.to_byte_array());
20362038

2037-
// Graduated: anchored to the original candidate's id, stamped with the confirmed RBF
2038-
// candidate's txid, with no separate record under the RBF candidate's id.
2039+
// Graduated: keyed by the splice-time-generated PaymentId (located here via the confirmed RBF
2040+
// candidate's txid it is stamped with), with no separate record under the RBF candidate's
2041+
// txid-derived id.
20392042
let payment = funding_payment(&node_b, rbf_txo.txid);
20402043
assert_eq!(payment.status, PaymentStatus::Succeeded);
20412044
assert!(matches!(
@@ -2111,6 +2114,201 @@ async fn rbf_splice_payment_reverts_after_deep_reorg() {
21112114
node_b.stop().unwrap();
21122115
}
21132116

2117+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
2118+
async fn splice_resumed_after_restart() {
2119+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
2120+
let chain_source = random_chain_source(&bitcoind, &electrsd);
2121+
2122+
// Set up node_a manually so it can be restarted with the same config.
2123+
let mut config_a = random_config(true);
2124+
config_a.store_type = TestStoreType::Sqlite;
2125+
let config_b = random_config(true);
2126+
let node_b = setup_node(&chain_source, config_b);
2127+
2128+
let onchain_balance_before_sat = {
2129+
let node_a = setup_node(&chain_source, config_a.clone());
2130+
2131+
let address_a = node_a.onchain_payment().new_address().unwrap();
2132+
let address_b = node_b.onchain_payment().new_address().unwrap();
2133+
let premine_amount_sat = 5_000_000;
2134+
premine_and_distribute_funds(
2135+
&bitcoind.client,
2136+
&electrsd.client,
2137+
vec![address_a, address_b],
2138+
Amount::from_sat(premine_amount_sat),
2139+
)
2140+
.await;
2141+
2142+
node_a.sync_wallets().unwrap();
2143+
node_b.sync_wallets().unwrap();
2144+
2145+
open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await;
2146+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
2147+
node_a.sync_wallets().unwrap();
2148+
node_b.sync_wallets().unwrap();
2149+
2150+
let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id());
2151+
expect_channel_ready_event!(node_b, node_a.node_id());
2152+
2153+
// Initiate a splice-out while disconnected: LDK accepts the contribution but cannot make
2154+
// progress before the restart below drops it, having neither negotiated nor persisted
2155+
// anything. Only the persisted splice intent allows resuming the splice.
2156+
node_a.disconnect(node_b.node_id()).unwrap();
2157+
let address = node_a.onchain_payment().new_address().unwrap();
2158+
node_a.splice_out(&user_channel_id_a, node_b.node_id(), &address, 500_000).unwrap();
2159+
2160+
let onchain_balance_before_sat = node_a.list_balances().total_onchain_balance_sats;
2161+
node_a.stop().unwrap();
2162+
onchain_balance_before_sat
2163+
};
2164+
2165+
// On restart, the reconciler resubmits the splice, which proceeds once the peer connects.
2166+
let node_a = setup_node(&chain_source, config_a.clone());
2167+
node_a.sync_wallets().unwrap();
2168+
let node_b_addr = node_b.listening_addresses().unwrap().first().unwrap().clone();
2169+
node_a.connect(node_b.node_id(), node_b_addr.clone(), false).unwrap();
2170+
2171+
let txo = expect_splice_negotiated_event!(node_a, node_b.node_id());
2172+
expect_splice_negotiated_event!(node_b, node_a.node_id());
2173+
2174+
wait_for_tx(&electrsd.client, txo.txid).await;
2175+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
2176+
node_a.sync_wallets().unwrap();
2177+
node_b.sync_wallets().unwrap();
2178+
2179+
expect_channel_ready_event!(node_a, node_b.node_id());
2180+
expect_channel_ready_event!(node_b, node_a.node_id());
2181+
2182+
assert!(
2183+
node_a.list_balances().total_onchain_balance_sats > onchain_balance_before_sat + 400_000,
2184+
"resumed splice-out should have moved ~500k sats to the on-chain balance",
2185+
);
2186+
2187+
// The locked splice cleared the intent, so another restart must not resubmit it.
2188+
node_a.stop().unwrap();
2189+
let node_a = setup_node(&chain_source, config_a);
2190+
node_a.sync_wallets().unwrap();
2191+
node_a.connect(node_b.node_id(), node_b_addr, false).unwrap();
2192+
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
2193+
assert!(node_a.next_event().is_none(), "completed splice should not be resubmitted");
2194+
2195+
node_a.stop().unwrap();
2196+
node_b.stop().unwrap();
2197+
}
2198+
2199+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
2200+
async fn splice_rbf_resumed_after_restart() {
2201+
// Use a custom bitcoind config with a lower incrementalrelayfee so that the +25 sat/kwu
2202+
// (0.1 sat/vB) RBF feerate bump satisfies BIP125's absolute fee increase requirement.
2203+
let bitcoind_exe = std::env::var("BITCOIND_EXE")
2204+
.ok()
2205+
.or_else(|| corepc_node::downloaded_exe_path().ok())
2206+
.expect(
2207+
"you need to provide an env var BITCOIND_EXE or specify a bitcoind version feature",
2208+
);
2209+
let mut bitcoind_conf = corepc_node::Conf::default();
2210+
bitcoind_conf.network = "regtest";
2211+
bitcoind_conf.args.push("-rest");
2212+
bitcoind_conf.args.push("-incrementalrelayfee=0.00000100");
2213+
let bitcoind = BitcoinD::with_conf(bitcoind_exe, &bitcoind_conf).unwrap();
2214+
2215+
let electrs_exe = std::env::var("ELECTRS_EXE")
2216+
.ok()
2217+
.or_else(electrsd::downloaded_exe_path)
2218+
.expect("you need to provide env var ELECTRS_EXE or specify an electrsd version feature");
2219+
let mut electrsd_conf = electrsd::Conf::default();
2220+
electrsd_conf.http_enabled = true;
2221+
electrsd_conf.network = "regtest";
2222+
let electrsd = ElectrsD::with_conf(electrs_exe, &bitcoind, &electrsd_conf).unwrap();
2223+
let chain_source = random_chain_source(&bitcoind, &electrsd);
2224+
2225+
// Set up node_a manually so it can be restarted with the same config.
2226+
let mut config_a = random_config(true);
2227+
config_a.store_type = TestStoreType::Sqlite;
2228+
let config_b = random_config(true);
2229+
let node_b = setup_node(&chain_source, config_b);
2230+
2231+
let original_txo = {
2232+
let node_a = setup_node(&chain_source, config_a.clone());
2233+
2234+
let address_a = node_a.onchain_payment().new_address().unwrap();
2235+
let address_b = node_b.onchain_payment().new_address().unwrap();
2236+
let premine_amount_sat = 5_000_000;
2237+
premine_and_distribute_funds(
2238+
&bitcoind.client,
2239+
&electrsd.client,
2240+
vec![address_a, address_b],
2241+
Amount::from_sat(premine_amount_sat),
2242+
)
2243+
.await;
2244+
2245+
node_a.sync_wallets().unwrap();
2246+
node_b.sync_wallets().unwrap();
2247+
2248+
open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await;
2249+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
2250+
node_a.sync_wallets().unwrap();
2251+
node_b.sync_wallets().unwrap();
2252+
2253+
let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id());
2254+
expect_channel_ready_event!(node_b, node_a.node_id());
2255+
2256+
// Negotiate a splice but leave its transaction unconfirmed so it can be fee-bumped.
2257+
node_a.splice_in(&user_channel_id_a, node_b.node_id(), 500_000).unwrap();
2258+
let original_txo = expect_splice_negotiated_event!(node_a, node_b.node_id());
2259+
expect_splice_negotiated_event!(node_b, node_a.node_id());
2260+
wait_for_tx(&electrsd.client, original_txo.txid).await;
2261+
node_a.sync_wallets().unwrap();
2262+
node_b.sync_wallets().unwrap();
2263+
2264+
// Bump the fee while disconnected and restart before anything could be negotiated: only
2265+
// the persisted intent knows about the fee bump, while LDK still has the negotiated
2266+
// splice at the original feerate.
2267+
node_a.disconnect(node_b.node_id()).unwrap();
2268+
node_a.bump_channel_funding_fee(&user_channel_id_a, node_b.node_id()).unwrap();
2269+
node_a.stop().unwrap();
2270+
original_txo
2271+
};
2272+
2273+
// On restart, the reconciler sees that the negotiated splice is still at a lower feerate
2274+
// than the persisted fee-bump intent and resubmits the bump.
2275+
let node_a = setup_node(&chain_source, config_a.clone());
2276+
node_a.sync_wallets().unwrap();
2277+
let node_b_addr = node_b.listening_addresses().unwrap().first().unwrap().clone();
2278+
node_a.connect(node_b.node_id(), node_b_addr.clone(), false).unwrap();
2279+
2280+
let rbf_txo = expect_splice_negotiated_event!(node_a, node_b.node_id());
2281+
expect_splice_negotiated_event!(node_b, node_a.node_id());
2282+
assert_ne!(original_txo, rbf_txo, "resubmitted RBF should produce a different funding txo");
2283+
2284+
// Restarting again must not resubmit the bump: the negotiated splice now carries it.
2285+
node_a.stop().unwrap();
2286+
let node_a = setup_node(&chain_source, config_a.clone());
2287+
node_a.sync_wallets().unwrap();
2288+
node_a.connect(node_b.node_id(), node_b_addr.clone(), false).unwrap();
2289+
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
2290+
assert!(node_a.next_event().is_none(), "carried-out fee bump should not be resubmitted");
2291+
2292+
wait_for_tx(&electrsd.client, rbf_txo.txid).await;
2293+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
2294+
node_a.sync_wallets().unwrap();
2295+
node_b.sync_wallets().unwrap();
2296+
2297+
expect_channel_ready_event!(node_a, node_b.node_id());
2298+
expect_channel_ready_event!(node_b, node_a.node_id());
2299+
2300+
// The locked fee bump cleared its intent, so a further restart must not resubmit it.
2301+
node_a.stop().unwrap();
2302+
let node_a = setup_node(&chain_source, config_a);
2303+
node_a.sync_wallets().unwrap();
2304+
node_a.connect(node_b.node_id(), node_b_addr, false).unwrap();
2305+
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
2306+
assert!(node_a.next_event().is_none(), "locked fee bump should not be resubmitted");
2307+
2308+
node_a.stop().unwrap();
2309+
node_b.stop().unwrap();
2310+
}
2311+
21142312
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
21152313
async fn splice_in_rbf_joins_counterparty_splice() {
21162314
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();

0 commit comments

Comments
 (0)