|
9 | 9 | // exhausted_probe_budget_blocks_new_probes |
10 | 10 | // Samples locked_msat across multiple probe cycles and asserts it never |
11 | 11 | // exceeds the configured max_locked_msat budget cap. |
| 12 | +// |
| 13 | +// probing_budget_restored_after_node_restart |
| 14 | +// Dispatches a probe, then stops node_b before the failure can propagate |
| 15 | +// back so the pending probe HTLC is preserved. Restarts node_a and asserts |
| 16 | +// the prober's locked_msat is rebuilt non-zero from list_recent_payments(). |
12 | 17 |
|
13 | 18 | mod common; |
14 | 19 | use std::sync::atomic::{AtomicBool, Ordering}; |
15 | 20 |
|
16 | 21 | use common::{ |
17 | 22 | expect_channel_ready_event, expect_event, generate_blocks_and_wait, open_channel, |
18 | 23 | premine_and_distribute_funds, random_chain_source, random_config, setup_bitcoind_and_electrsd, |
19 | | - setup_node, wait_for_channel_ready_to_send, TestNode, |
| 24 | + setup_node, wait_for_channel_ready_to_send, TestNode, TestStoreType, |
20 | 25 | }; |
21 | 26 |
|
22 | 27 | use ldk_node::bitcoin::Amount; |
@@ -195,6 +200,105 @@ async fn probe_budget_increments_and_decrements() { |
195 | 200 | node_c.stop().unwrap(); |
196 | 201 | } |
197 | 202 |
|
| 203 | +/// Verifies that `locked_msat` is restored after the node is stopped and restarted |
| 204 | +/// while a probe is still in flight. |
| 205 | +/// |
| 206 | +/// Race-sensitive: once a probe is dispatched, the failure round-trip |
| 207 | +/// (`A→B→C → C fails back → B → A`) resolves it within milliseconds. To keep the |
| 208 | +/// HTLC pending across the restart we observe `locked_msat > 0` and then *immediately* |
| 209 | +/// stop `node_b`, which prevents `B` from forwarding the failure back to `A`. |
| 210 | +/// The pending Probe entry persists in `node_a`'s channel manager and must be |
| 211 | +/// rebuilt by the prober's `locked_msat` on restart via `list_recent_payments()`. |
| 212 | +#[tokio::test(flavor = "multi_thread")] |
| 213 | +async fn probing_budget_restored_after_node_restart() { |
| 214 | + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); |
| 215 | + let chain_source = random_chain_source(&bitcoind, &electrsd); |
| 216 | + |
| 217 | + let node_b = setup_node(&chain_source, random_config(false)); |
| 218 | + let node_c = setup_node(&chain_source, random_config(false)); |
| 219 | + |
| 220 | + let mut config_a = random_config(false); |
| 221 | + // Use a pure on-disk store so state survives the restart. |
| 222 | + config_a.store_type = TestStoreType::Sqlite; |
| 223 | + let strategy = FixedPathStrategy::new(); |
| 224 | + config_a.probing = Some( |
| 225 | + ProbingConfigBuilder::custom(strategy.clone()) |
| 226 | + .interval(Duration::from_millis(PROBING_INTERVAL_MILLISECONDS)) |
| 227 | + .max_locked_msat(10 * PROBE_AMOUNT_MSAT) |
| 228 | + .build(), |
| 229 | + ); |
| 230 | + let restart_config = config_a.clone(); |
| 231 | + let node_a = setup_node(&chain_source, config_a); |
| 232 | + |
| 233 | + let addr_a = node_a.onchain_payment().new_address().unwrap(); |
| 234 | + let addr_b = node_b.onchain_payment().new_address().unwrap(); |
| 235 | + premine_and_distribute_funds( |
| 236 | + &bitcoind.client, |
| 237 | + &electrsd.client, |
| 238 | + vec![addr_a, addr_b], |
| 239 | + Amount::from_sat(2_000_000), |
| 240 | + ) |
| 241 | + .await; |
| 242 | + node_a.sync_wallets().unwrap(); |
| 243 | + node_b.sync_wallets().unwrap(); |
| 244 | + |
| 245 | + open_channel(&node_a, &node_b, 1_000_000, true, &electrsd).await; |
| 246 | + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 1).await; |
| 247 | + node_b.sync_wallets().unwrap(); |
| 248 | + open_channel(&node_b, &node_c, 1_000_000, true, &electrsd).await; |
| 249 | + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; |
| 250 | + |
| 251 | + node_a.sync_wallets().unwrap(); |
| 252 | + node_b.sync_wallets().unwrap(); |
| 253 | + node_c.sync_wallets().unwrap(); |
| 254 | + |
| 255 | + expect_channel_ready_event!(node_a, node_b.node_id()); |
| 256 | + expect_event!(node_b, ChannelReady); |
| 257 | + expect_event!(node_b, ChannelReady); |
| 258 | + expect_event!(node_c, ChannelReady); |
| 259 | + |
| 260 | + strategy.set_path(build_probe_path(&node_a, &node_b, &node_c, PROBE_AMOUNT_MSAT)); |
| 261 | + wait_for_channel_ready_to_send(&node_a, &node_b, PROBE_AMOUNT_MSAT + 1000).await; |
| 262 | + wait_for_channel_ready_to_send(&node_b, &node_c, PROBE_AMOUNT_MSAT).await; |
| 263 | + |
| 264 | + strategy.start_probing(); |
| 265 | + let went_up = tokio::time::timeout(Duration::from_secs(30), async { |
| 266 | + loop { |
| 267 | + if node_a.prober().unwrap().locked_msat() > 0 { |
| 268 | + break; |
| 269 | + } |
| 270 | + tokio::time::sleep(Duration::from_millis(10)).await; |
| 271 | + } |
| 272 | + }) |
| 273 | + .await |
| 274 | + .is_ok(); |
| 275 | + assert!(went_up, "locked_msat never increased — no probe was dispatched"); |
| 276 | + |
| 277 | + node_b.stop().unwrap(); |
| 278 | + strategy.stop_probing(); |
| 279 | + |
| 280 | + let locked_before = node_a.prober().unwrap().locked_msat(); |
| 281 | + println!("Before restart: locked_msat = {}", locked_before); |
| 282 | + assert!(locked_before > 0, "probe resolved before we could stop node_b — flaky timing"); |
| 283 | + |
| 284 | + node_a.stop().unwrap(); |
| 285 | + |
| 286 | + // Restart node_a from the same persisted state. |
| 287 | + let node_a = setup_node(&chain_source, restart_config); |
| 288 | + |
| 289 | + let locked_after = node_a.prober().unwrap().locked_msat(); |
| 290 | + println!("After restart: locked_msat = {}", locked_after); |
| 291 | + assert!( |
| 292 | + locked_after > 0, |
| 293 | + "locked_msat was not restored after restart (before={} after={})", |
| 294 | + locked_before, |
| 295 | + locked_after |
| 296 | + ); |
| 297 | + |
| 298 | + node_a.stop().unwrap(); |
| 299 | + node_c.stop().unwrap(); |
| 300 | +} |
| 301 | + |
198 | 302 | /// Verifies that `locked_msat` never exceeds `max_locked_msat` across multiple probe cycles. |
199 | 303 | #[tokio::test(flavor = "multi_thread")] |
200 | 304 | async fn exhausted_probe_budget_blocks_new_probes() { |
|
0 commit comments