@@ -26,6 +26,21 @@ use esplora_client::Builder;
2626use core:: ops:: Deref ;
2727use std:: collections:: HashSet ;
2828
29+ /// Maximum number of concurrent in-flight Esplora requests issued while syncing
30+ /// confirmed/unconfirmed transactions (async client only).
31+ ///
32+ /// The Esplora chain sync re-confirms every watched transaction/output on each
33+ /// pass, which is one or more HTTP round-trips each. Against a remote Esplora
34+ /// these run sequentially in the stock client, so sync wall-time scales with
35+ /// `watched_set * round_trip_latency` and easily exceeds an LDK wallet-sync
36+ /// timeout on wallets with real channel history. We fan these out with a bounded
37+ /// concurrency instead. The bound is deliberately small: when many nodes sync
38+ /// against a shared, rate-limited endpoint the effective request rate is the
39+ /// fleet aggregate, so a low per-node concurrency keeps us under the server's
40+ /// per-client limit while still removing the strictly-serial latency floor.
41+ #[ cfg( feature = "async-interface" ) ]
42+ const ESPLORA_SYNC_CONCURRENCY : usize = 4 ;
43+
2944/// Synchronizes LDK with a given [`Esplora`] server.
3045///
3146/// Needs to be registered with a [`ChainMonitor`] via the [`Filter`] interface to be informed of
@@ -298,19 +313,114 @@ where
298313
299314 let mut confirmed_txs: Vec < ConfirmedTx > = Vec :: new ( ) ;
300315
316+ // Phase A: resolve the confirmation status of each directly-watched
317+ // transaction. `watched_transactions` is a set (unique txids), so a tx
318+ // resolved here is never a duplicate; the async path fans the lookups
319+ // out with bounded concurrency and merges the results.
320+ #[ cfg( feature = "async-interface" ) ]
321+ {
322+ use futures:: stream:: { self , StreamExt } ;
323+ let results: Vec < Result < Option < ConfirmedTx > , InternalError > > =
324+ stream:: iter ( sync_state. watched_transactions . iter ( ) . copied ( ) )
325+ . map ( |txid| async move { self . get_confirmed_tx ( txid, None , None ) . await } )
326+ . buffer_unordered ( ESPLORA_SYNC_CONCURRENCY )
327+ . collect ( )
328+ . await ;
329+ for r in results {
330+ if let Some ( confirmed_tx) = r? {
331+ if !confirmed_txs. iter ( ) . any ( |ctx| ctx. txid == confirmed_tx. txid ) {
332+ confirmed_txs. push ( confirmed_tx) ;
333+ }
334+ }
335+ }
336+ }
337+ #[ cfg( not( feature = "async-interface" ) ) ]
301338 for txid in & sync_state. watched_transactions {
302339 if confirmed_txs. iter ( ) . any ( |ctx| ctx. txid == * txid) {
303340 continue ;
304341 }
305- if let Some ( confirmed_tx) = maybe_await ! ( self . get_confirmed_tx( * txid, None , None ) ) ? {
342+ if let Some ( confirmed_tx) = self . get_confirmed_tx ( * txid, None , None ) ? {
306343 confirmed_txs. push ( confirmed_tx) ;
307344 }
308345 }
309346
347+ // Phase B: for each watched output, fetch its spend status and, if it was
348+ // spent, resolve the spending transaction. Phase A is fully merged into
349+ // `confirmed_txs` before this runs, so the consistency check below sees
350+ // the same state the sequential version did.
351+ #[ cfg( feature = "async-interface" ) ]
352+ {
353+ use futures:: stream:: { self , StreamExt } ;
354+
355+ // B1: fan out the output-status lookups.
356+ let outpoints: Vec < _ > =
357+ sync_state. watched_outputs . values ( ) . map ( |o| o. outpoint ) . collect ( ) ;
358+ let status_results: Vec < Result < Option < esplora_client:: OutputStatus > , InternalError > > =
359+ stream:: iter ( outpoints. into_iter ( ) )
360+ . map ( |outpoint| async move {
361+ self . client
362+ . get_output_status ( & outpoint. txid , outpoint. index as u64 )
363+ . await
364+ . map_err ( InternalError :: from)
365+ } )
366+ . buffer_unordered ( ESPLORA_SYNC_CONCURRENCY )
367+ . collect ( )
368+ . await ;
369+
370+ // B2: post-process sequentially, preserving every consistency check
371+ // the sequential version performed, and build the to-fetch list.
372+ let phase_a_txids: HashSet < Txid > =
373+ confirmed_txs. iter ( ) . map ( |ctx| ctx. txid ) . collect ( ) ;
374+ let mut to_fetch: Vec < ( Txid , Option < BlockHash > , Option < u32 > ) > = Vec :: new ( ) ;
375+ // `transpose` drops outputs with no status while still surfacing any
376+ // lookup error through the `?` below.
377+ for status_res in status_results. into_iter ( ) . filter_map ( Result :: transpose) {
378+ let output_status = status_res?;
379+ let ( Some ( spending_txid) , Some ( spending_tx_status) ) =
380+ ( output_status. txid , output_status. status )
381+ else {
382+ continue ;
383+ } ;
384+
385+ if phase_a_txids. contains ( & spending_txid) {
386+ // Phase A already resolved this spend as confirmed; the server
387+ // flipping it back to unconfirmed is an inconsistency.
388+ if !spending_tx_status. confirmed {
389+ log_trace ! ( self . logger, "Inconsistency: Detected previously-confirmed Tx {} as unconfirmed" , spending_txid) ;
390+ return Err ( InternalError :: Inconsistency ) ;
391+ }
392+ continue ;
393+ }
394+
395+ to_fetch. push ( (
396+ spending_txid,
397+ spending_tx_status. block_hash ,
398+ spending_tx_status. block_height ,
399+ ) ) ;
400+ }
401+
402+ // B3: fan out the dependent confirmed-tx lookups.
403+ let dep_results: Vec < Result < Option < ConfirmedTx > , InternalError > > =
404+ stream:: iter ( to_fetch. into_iter ( ) )
405+ . map ( |( txid, bh, height) | async move {
406+ self . get_confirmed_tx ( txid, bh, height) . await
407+ } )
408+ . buffer_unordered ( ESPLORA_SYNC_CONCURRENCY )
409+ . collect ( )
410+ . await ;
411+ for r in dep_results {
412+ if let Some ( confirmed_tx) = r? {
413+ if !confirmed_txs. iter ( ) . any ( |ctx| ctx. txid == confirmed_tx. txid ) {
414+ confirmed_txs. push ( confirmed_tx) ;
415+ }
416+ }
417+ }
418+ }
419+ #[ cfg( not( feature = "async-interface" ) ) ]
310420 for ( _, output) in & sync_state. watched_outputs {
311- if let Some ( output_status) = maybe_await ! ( self
421+ if let Some ( output_status) = self
312422 . client
313- . get_output_status( & output. outpoint. txid, output. outpoint. index as u64 ) ) ?
423+ . get_output_status ( & output. outpoint . txid , output. outpoint . index as u64 ) ?
314424 {
315425 if let Some ( spending_txid) = output_status. txid {
316426 if let Some ( spending_tx_status) = output_status. status {
@@ -324,11 +434,11 @@ where
324434 }
325435 }
326436
327- if let Some ( confirmed_tx) = maybe_await ! ( self . get_confirmed_tx(
437+ if let Some ( confirmed_tx) = self . get_confirmed_tx (
328438 spending_txid,
329439 spending_tx_status. block_hash ,
330440 spending_tx_status. block_height ,
331- ) ) ? {
441+ ) ? {
332442 confirmed_txs. push ( confirmed_tx) ;
333443 }
334444 }
@@ -436,9 +546,48 @@ where
436546
437547 let mut unconfirmed_txs = Vec :: new ( ) ;
438548
549+ // The async path fans the per-block status checks out with bounded
550+ // concurrency. The `None` block hash is a hard invariant violation
551+ // (pre-0.0.113 channel), so we screen for it before fanning out rather
552+ // than panicking from inside a concurrent task.
553+ #[ cfg( feature = "async-interface" ) ]
554+ {
555+ use futures:: stream:: { self , StreamExt } ;
556+ let mut items: Vec < ( Txid , BlockHash ) > = Vec :: with_capacity ( relevant_txids. len ( ) ) ;
557+ for ( txid, _conf_height, block_hash_opt) in relevant_txids {
558+ if let Some ( block_hash) = block_hash_opt {
559+ items. push ( ( txid, block_hash) ) ;
560+ } else {
561+ log_error ! ( self . logger, "Untracked confirmation of funding transaction. Please ensure none of your channels had been created with LDK prior to version 0.0.113!" ) ;
562+ panic ! ( "Untracked confirmation of funding transaction. Please ensure none of your channels had been created with LDK prior to version 0.0.113!" ) ;
563+ }
564+ }
565+ let results: Vec < ( Txid , Result < esplora_client:: BlockStatus , InternalError > ) > =
566+ stream:: iter ( items. into_iter ( ) )
567+ . map ( |( txid, block_hash) | async move {
568+ let r = self
569+ . client
570+ . get_block_status ( & block_hash)
571+ . await
572+ . map_err ( InternalError :: from) ;
573+ ( txid, r)
574+ } )
575+ . buffer_unordered ( ESPLORA_SYNC_CONCURRENCY )
576+ . collect ( )
577+ . await ;
578+ for ( txid, status_res) in results {
579+ let block_status = status_res?;
580+ if block_status. in_best_chain {
581+ // Skip if the block in question is still confirmed.
582+ continue ;
583+ }
584+ unconfirmed_txs. push ( txid) ;
585+ }
586+ }
587+ #[ cfg( not( feature = "async-interface" ) ) ]
439588 for ( txid, _conf_height, block_hash_opt) in relevant_txids {
440589 if let Some ( block_hash) = block_hash_opt {
441- let block_status = maybe_await ! ( self . client. get_block_status( & block_hash) ) ?;
590+ let block_status = self . client . get_block_status ( & block_hash) ?;
442591 if block_status. in_best_chain {
443592 // Skip if the block in question is still confirmed.
444593 continue ;
0 commit comments