Skip to content

Commit 7265f8c

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. Also cover the id-agreement race: splice_payment_tracked_across_restart_before_lock stops the node right after splice negotiation, lets the counterparty's broadcast confirm while it is down, and asserts after restart that wallet sync and classification -- landing in either order -- produce exactly one payment record, keyed by the splice-time id rather than a txid-derived one, through to Succeeded. 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> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent bc76ab8 commit 7265f8c

2 files changed

Lines changed: 318 additions & 1 deletion

File tree

src/lib.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1904,6 +1904,10 @@ impl Node {
19041904
/// it. Once negotiation with the counterparty is complete, the channel remains operational
19051905
/// while waiting for a new funding transaction to confirm.
19061906
///
1907+
/// The splice is retried automatically, including across restarts, until it either completes or
1908+
/// fails for a reason retrying cannot address, at which point [`Event::SpliceNegotiationFailed`]
1909+
/// is emitted.
1910+
///
19071911
/// # Experimental API
19081912
///
19091913
/// This API is experimental. Currently, a splice-in will be marked as an outbound payment, but
@@ -1928,6 +1932,10 @@ impl Node {
19281932
/// it. Once negotiation with the counterparty is complete, the channel remains operational
19291933
/// while waiting for a new funding transaction to confirm.
19301934
///
1935+
/// The splice is retried automatically, including across restarts, until it either completes or
1936+
/// fails for a reason retrying cannot address, at which point [`Event::SpliceNegotiationFailed`]
1937+
/// is emitted.
1938+
///
19311939
/// # Experimental API
19321940
///
19331941
/// This API is experimental. Currently, a splice-in will be marked as an outbound payment, but
@@ -1944,6 +1952,10 @@ impl Node {
19441952
/// it. Once negotiation with the counterparty is complete, the channel remains operational
19451953
/// while waiting for a new funding transaction to confirm.
19461954
///
1955+
/// The splice is retried automatically, including across restarts, until it either completes or
1956+
/// fails for a reason retrying cannot address, at which point [`Event::SpliceNegotiationFailed`]
1957+
/// is emitted.
1958+
///
19471959
/// # Experimental API
19481960
///
19491961
/// This API is experimental. Currently, a splice-out will be marked as an inbound payment if
@@ -2041,6 +2053,10 @@ impl Node {
20412053
/// Fee-bumps the pending splice on a channel by replacing its in-flight funding transaction
20422054
/// (RBF). The splice's amount and destination are preserved; only the fee rate is raised.
20432055
/// Errors if the channel has no pending splice to bump.
2056+
///
2057+
/// The fee bump is retried automatically, including across restarts, until it either completes
2058+
/// or fails for a reason retrying cannot address, at which point
2059+
/// [`Event::SpliceNegotiationFailed`] is emitted.
20442060
pub fn bump_channel_funding_fee(
20452061
&self, user_channel_id: &UserChannelId, counterparty_node_id: PublicKey,
20462062
) -> Result<(), Error> {

tests/integration_tests_rust.rs

Lines changed: 302 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2003,7 +2003,9 @@ async fn run_rbf_splice_channel_test(confirm_original: bool) {
20032003
},
20042004
}
20052005
assert_eq!(payment.status, PaymentStatus::Pending);
2006-
// Only one Onchain Pending payment for this splice attempt (not one per candidate).
2006+
// Only one Onchain Pending payment for this splice attempt (not one per candidate). This also
2007+
// guards the intent-clobber fix: had the sync above cleared this splice's live intent, the
2008+
// bump would not have found it and would have minted a second record under a fresh PaymentId.
20072009
let splice_payments = node_b.list_payments_with_filter(|p| {
20082010
p.direction == PaymentDirection::Outbound
20092011
&& matches!(p.kind, PaymentKind::Onchain { .. })
@@ -2221,6 +2223,305 @@ async fn splice_payment_reorged_to_unconfirmed() {
22212223
node_b.stop().unwrap();
22222224
}
22232225

2226+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
2227+
async fn splice_resumed_after_restart() {
2228+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
2229+
let chain_source = random_chain_source(&bitcoind, &electrsd);
2230+
2231+
// Set up node_a manually so it can be restarted with the same config.
2232+
let mut config_a = random_config();
2233+
config_a.store_type = TestStoreType::Sqlite;
2234+
let config_b = random_config();
2235+
let node_b = setup_node(&chain_source, config_b);
2236+
2237+
let onchain_balance_before_sat = {
2238+
let node_a = setup_node(&chain_source, config_a.clone());
2239+
2240+
let address_a = node_a.onchain_payment().new_address().unwrap();
2241+
let address_b = node_b.onchain_payment().new_address().unwrap();
2242+
let premine_amount_sat = 5_000_000;
2243+
premine_and_distribute_funds(
2244+
&bitcoind.client,
2245+
&electrsd.client,
2246+
vec![address_a, address_b],
2247+
Amount::from_sat(premine_amount_sat),
2248+
)
2249+
.await;
2250+
2251+
node_a.sync_wallets().unwrap();
2252+
node_b.sync_wallets().unwrap();
2253+
2254+
open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await;
2255+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
2256+
node_a.sync_wallets().unwrap();
2257+
node_b.sync_wallets().unwrap();
2258+
2259+
let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id());
2260+
expect_channel_ready_event!(node_b, node_a.node_id());
2261+
2262+
// Initiate a splice-out while disconnected: LDK accepts the contribution but cannot make
2263+
// progress before the restart below drops it, having neither negotiated nor persisted
2264+
// anything. Only the persisted splice intent allows resuming the splice.
2265+
node_a.disconnect(node_b.node_id()).unwrap();
2266+
let address = node_a.onchain_payment().new_address().unwrap();
2267+
node_a.splice_out(&user_channel_id_a, node_b.node_id(), &address, 500_000).unwrap();
2268+
2269+
let onchain_balance_before_sat = node_a.list_balances().total_onchain_balance_sats;
2270+
node_a.stop().unwrap();
2271+
onchain_balance_before_sat
2272+
};
2273+
2274+
// On restart, the reconciler resubmits the splice, which proceeds once the peer connects.
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 txo = expect_splice_negotiated_event!(node_a, node_b.node_id());
2281+
expect_splice_negotiated_event!(node_b, node_a.node_id());
2282+
2283+
wait_for_tx(&electrsd.client, txo.txid).await;
2284+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
2285+
node_a.sync_wallets().unwrap();
2286+
node_b.sync_wallets().unwrap();
2287+
2288+
expect_channel_ready_event!(node_a, node_b.node_id());
2289+
expect_channel_ready_event!(node_b, node_a.node_id());
2290+
2291+
assert!(
2292+
node_a.list_balances().total_onchain_balance_sats > onchain_balance_before_sat + 400_000,
2293+
"resumed splice-out should have moved ~500k sats to the on-chain balance",
2294+
);
2295+
2296+
// The locked splice cleared the intent, so another restart must not resubmit it.
2297+
node_a.stop().unwrap();
2298+
let node_a = setup_node(&chain_source, config_a);
2299+
node_a.sync_wallets().unwrap();
2300+
node_a.connect(node_b.node_id(), node_b_addr, false).unwrap();
2301+
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
2302+
assert!(node_a.next_event().is_none(), "completed splice should not be resubmitted");
2303+
2304+
node_a.stop().unwrap();
2305+
node_b.stop().unwrap();
2306+
}
2307+
2308+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
2309+
async fn splice_rbf_resumed_after_restart() {
2310+
// Use a custom bitcoind config with a lower incrementalrelayfee so that the +25 sat/kwu
2311+
// (0.1 sat/vB) RBF feerate bump satisfies BIP125's absolute fee increase requirement.
2312+
let bitcoind_exe = std::env::var("BITCOIND_EXE")
2313+
.ok()
2314+
.or_else(|| corepc_node::downloaded_exe_path().ok())
2315+
.expect(
2316+
"you need to provide an env var BITCOIND_EXE or specify a bitcoind version feature",
2317+
);
2318+
let mut bitcoind_conf = corepc_node::Conf::default();
2319+
bitcoind_conf.network = "regtest";
2320+
bitcoind_conf.args.push("-rest");
2321+
bitcoind_conf.args.push("-incrementalrelayfee=0.00000100");
2322+
let bitcoind = BitcoinD::with_conf(bitcoind_exe, &bitcoind_conf).unwrap();
2323+
2324+
let electrs_exe = std::env::var("ELECTRS_EXE")
2325+
.ok()
2326+
.or_else(electrsd::downloaded_exe_path)
2327+
.expect("you need to provide env var ELECTRS_EXE or specify an electrsd version feature");
2328+
let mut electrsd_conf = electrsd::Conf::default();
2329+
electrsd_conf.http_enabled = true;
2330+
electrsd_conf.network = "regtest";
2331+
let electrsd = ElectrsD::with_conf(electrs_exe, &bitcoind, &electrsd_conf).unwrap();
2332+
let chain_source = random_chain_source(&bitcoind, &electrsd);
2333+
2334+
// Set up node_a manually so it can be restarted with the same config.
2335+
let mut config_a = random_config();
2336+
config_a.store_type = TestStoreType::Sqlite;
2337+
let config_b = random_config();
2338+
let node_b = setup_node(&chain_source, config_b);
2339+
2340+
let original_txo = {
2341+
let node_a = setup_node(&chain_source, config_a.clone());
2342+
2343+
let address_a = node_a.onchain_payment().new_address().unwrap();
2344+
let address_b = node_b.onchain_payment().new_address().unwrap();
2345+
let premine_amount_sat = 5_000_000;
2346+
premine_and_distribute_funds(
2347+
&bitcoind.client,
2348+
&electrsd.client,
2349+
vec![address_a, address_b],
2350+
Amount::from_sat(premine_amount_sat),
2351+
)
2352+
.await;
2353+
2354+
node_a.sync_wallets().unwrap();
2355+
node_b.sync_wallets().unwrap();
2356+
2357+
open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await;
2358+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
2359+
node_a.sync_wallets().unwrap();
2360+
node_b.sync_wallets().unwrap();
2361+
2362+
let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id());
2363+
expect_channel_ready_event!(node_b, node_a.node_id());
2364+
2365+
// Negotiate a splice but leave its transaction unconfirmed so it can be fee-bumped.
2366+
node_a.splice_in(&user_channel_id_a, node_b.node_id(), 500_000).unwrap();
2367+
let original_txo = expect_splice_negotiated_event!(node_a, node_b.node_id());
2368+
expect_splice_negotiated_event!(node_b, node_a.node_id());
2369+
wait_for_tx(&electrsd.client, original_txo.txid).await;
2370+
node_a.sync_wallets().unwrap();
2371+
node_b.sync_wallets().unwrap();
2372+
2373+
// Bump the fee while disconnected and restart before anything could be negotiated: only
2374+
// the persisted intent knows about the fee bump, while LDK still has the negotiated
2375+
// splice at the original feerate.
2376+
node_a.disconnect(node_b.node_id()).unwrap();
2377+
node_a.bump_channel_funding_fee(&user_channel_id_a, node_b.node_id()).unwrap();
2378+
node_a.stop().unwrap();
2379+
original_txo
2380+
};
2381+
2382+
// On restart, the reconciler sees that the negotiated splice is still at a lower feerate
2383+
// than the persisted fee-bump intent and resubmits the bump.
2384+
let node_a = setup_node(&chain_source, config_a.clone());
2385+
node_a.sync_wallets().unwrap();
2386+
let node_b_addr = node_b.listening_addresses().unwrap().first().unwrap().clone();
2387+
node_a.connect(node_b.node_id(), node_b_addr.clone(), false).unwrap();
2388+
2389+
let rbf_txo = expect_splice_negotiated_event!(node_a, node_b.node_id());
2390+
expect_splice_negotiated_event!(node_b, node_a.node_id());
2391+
assert_ne!(original_txo, rbf_txo, "resubmitted RBF should produce a different funding txo");
2392+
2393+
// Restarting again must not resubmit the bump: the negotiated splice now carries it.
2394+
node_a.stop().unwrap();
2395+
let node_a = setup_node(&chain_source, config_a.clone());
2396+
node_a.sync_wallets().unwrap();
2397+
node_a.connect(node_b.node_id(), node_b_addr.clone(), false).unwrap();
2398+
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
2399+
assert!(node_a.next_event().is_none(), "carried-out fee bump should not be resubmitted");
2400+
2401+
wait_for_tx(&electrsd.client, rbf_txo.txid).await;
2402+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
2403+
node_a.sync_wallets().unwrap();
2404+
node_b.sync_wallets().unwrap();
2405+
2406+
expect_channel_ready_event!(node_a, node_b.node_id());
2407+
expect_channel_ready_event!(node_b, node_a.node_id());
2408+
2409+
// The locked fee bump cleared its intent, so a further restart must not resubmit it.
2410+
node_a.stop().unwrap();
2411+
let node_a = setup_node(&chain_source, config_a);
2412+
node_a.sync_wallets().unwrap();
2413+
node_a.connect(node_b.node_id(), node_b_addr, false).unwrap();
2414+
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
2415+
assert!(node_a.next_event().is_none(), "locked fee bump should not be resubmitted");
2416+
2417+
node_a.stop().unwrap();
2418+
node_b.stop().unwrap();
2419+
}
2420+
2421+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
2422+
async fn splice_payment_tracked_across_restart_before_lock() {
2423+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
2424+
let chain_source = random_chain_source(&bitcoind, &electrsd);
2425+
2426+
// Set up node_a manually so it can be restarted with the same config.
2427+
let mut config_a = random_config();
2428+
config_a.store_type = TestStoreType::Sqlite;
2429+
let config_b = random_config();
2430+
let node_b = setup_node(&chain_source, config_b);
2431+
2432+
let splice_txid = {
2433+
let node_a = setup_node(&chain_source, config_a.clone());
2434+
2435+
let address_a = node_a.onchain_payment().new_address().unwrap();
2436+
let address_b = node_b.onchain_payment().new_address().unwrap();
2437+
let premine_amount_sat = 5_000_000;
2438+
premine_and_distribute_funds(
2439+
&bitcoind.client,
2440+
&electrsd.client,
2441+
vec![address_a, address_b],
2442+
Amount::from_sat(premine_amount_sat),
2443+
)
2444+
.await;
2445+
2446+
node_a.sync_wallets().unwrap();
2447+
node_b.sync_wallets().unwrap();
2448+
2449+
open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await;
2450+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
2451+
node_a.sync_wallets().unwrap();
2452+
node_b.sync_wallets().unwrap();
2453+
2454+
let user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id());
2455+
expect_channel_ready_event!(node_b, node_a.node_id());
2456+
2457+
node_a.splice_in(&user_channel_id_a, node_b.node_id(), 500_000).unwrap();
2458+
let txo = expect_splice_negotiated_event!(node_a, node_b.node_id());
2459+
expect_splice_negotiated_event!(node_b, node_a.node_id());
2460+
2461+
// Stop node_a as soon as the splice is negotiated. node_b broadcasts the transaction
2462+
// either way, so it reaches the chain while node_a is offline. Depending on timing,
2463+
// node_a may or may not have classified its own broadcast into a payment record before
2464+
// stopping; the assertions below must hold in both cases.
2465+
node_a.stop().unwrap();
2466+
txo.txid
2467+
};
2468+
2469+
// Confirm the splice while node_a is offline, but keep it short of the depth at which it
2470+
// locks, so node_a restarts with its splice intent still live.
2471+
wait_for_tx(&electrsd.client, splice_txid).await;
2472+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await;
2473+
2474+
// After the restart, wallet sync and classification must agree on the splice-time
2475+
// `PaymentId` no matter which of them sees the confirmed transaction first: exactly one
2476+
// payment record, and not one keyed by a txid-derived id.
2477+
let node_a = setup_node(&chain_source, config_a);
2478+
node_a.sync_wallets().unwrap();
2479+
2480+
let splice_payments = |node: &Node| {
2481+
node.list_payments_with_filter(
2482+
|p| matches!(p.kind, PaymentKind::Onchain { txid, .. } if txid == splice_txid),
2483+
)
2484+
};
2485+
let payments = splice_payments(&node_a);
2486+
assert_eq!(
2487+
payments.len(),
2488+
1,
2489+
"expected exactly one payment record for the splice, got {}: {:#?}",
2490+
payments.len(),
2491+
payments,
2492+
);
2493+
assert_ne!(
2494+
payments[0].id,
2495+
PaymentId(splice_txid.to_byte_array()),
2496+
"the splice payment must keep its splice-time id, not a txid-derived fallback",
2497+
);
2498+
assert_eq!(payments[0].status, PaymentStatus::Pending);
2499+
2500+
// Reconnect and let the splice lock: the single record graduates instead of gaining a
2501+
// duplicate.
2502+
let node_b_addr = node_b.listening_addresses().unwrap().first().unwrap().clone();
2503+
node_a.connect(node_b.node_id(), node_b_addr, false).unwrap();
2504+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 5).await;
2505+
node_a.sync_wallets().unwrap();
2506+
node_b.sync_wallets().unwrap();
2507+
2508+
expect_channel_ready_event!(node_a, node_b.node_id());
2509+
expect_channel_ready_event!(node_b, node_a.node_id());
2510+
2511+
let payments = splice_payments(&node_a);
2512+
assert_eq!(
2513+
payments.len(),
2514+
1,
2515+
"expected exactly one payment record after the splice locked, got {}: {:#?}",
2516+
payments.len(),
2517+
payments,
2518+
);
2519+
assert_eq!(payments[0].status, PaymentStatus::Succeeded);
2520+
2521+
node_a.stop().unwrap();
2522+
node_b.stop().unwrap();
2523+
}
2524+
22242525
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
22252526
async fn splice_in_rbf_joins_counterparty_splice() {
22262527
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();

0 commit comments

Comments
 (0)