@@ -392,6 +392,201 @@ async fn test_sweep_to_ln() {
392392 . await ;
393393}
394394
395+ #[ tokio:: test( flavor = "multi_thread" ) ]
396+ #[ test_log:: test]
397+ async fn test_graduated_transfer_keeps_backend_settle_time ( ) {
398+ test_utils:: run_test ( |params| async move {
399+ let wallet = Arc :: clone ( & params. wallet ) ;
400+ let lsp = Arc :: clone ( & params. lsp ) ;
401+ let third_party = Arc :: clone ( & params. third_party ) ;
402+
403+ let starting_lsp_channels = lsp. list_channels ( ) ;
404+
405+ // Disable rebalancing so both receives settle in the trusted wallet before
406+ // the rebalancer ever observes them. This way the only thing that stamps a
407+ // discovery time is the rebalancer run we trigger later.
408+ wallet. set_rebalance_enabled ( false ) . await ;
409+
410+ let limit = wallet. get_tunables ( ) ;
411+ let first_amt =
412+ Amount :: from_milli_sats ( limit. trusted_balance_limit . milli_sats ( ) / 2 ) . unwrap ( ) ;
413+ let second_amt = Amount :: from_milli_sats ( limit. trusted_balance_limit . milli_sats ( ) ) . unwrap ( ) ;
414+
415+ let uri = wallet. get_single_use_receive_uri ( Some ( first_amt) ) . await . unwrap ( ) ;
416+ assert ! ( uri. from_trusted) ;
417+ third_party. bolt11_payment ( ) . send ( & uri. invoice , None ) . unwrap ( ) ;
418+ test_utils:: wait_for_condition ( "first receive" , || async {
419+ wallet. get_balance ( ) . await . unwrap ( ) . available_balance ( ) >= first_amt
420+ } )
421+ . await ;
422+
423+ let uri = wallet. get_single_use_receive_uri ( Some ( second_amt) ) . await . unwrap ( ) ;
424+ assert ! ( uri. from_trusted) ;
425+ third_party. bolt11_payment ( ) . send ( & uri. invoice , None ) . unwrap ( ) ;
426+ test_utils:: wait_for_condition ( "second receive" , || async {
427+ wallet. get_balance ( ) . await . unwrap ( ) . available_balance ( )
428+ >= first_amt. saturating_add ( second_amt)
429+ } )
430+ . await ;
431+
432+ // The backend recorded both settle times ~now. Sleep so any later discovery
433+ // stamp lands a clearly later wall-clock time, then mark that boundary just
434+ // before we let the rebalancer run.
435+ tokio:: time:: sleep ( Duration :: from_secs ( 3 ) ) . await ;
436+ let enabled_at = SystemTime :: now ( ) . duration_since ( UNIX_EPOCH ) . unwrap ( ) ;
437+
438+ // Re-enable rebalancing and start draining events. Marking each event handled
439+ // re-triggers the rebalancer, which observes the payments for the first time
440+ // (stamping discovery times >= enabled_at) and graduates the trusted balance
441+ // into a Lightning channel.
442+ wallet. set_rebalance_enabled ( true ) . await ;
443+ test_utils:: wait_for_condition ( "new channel opened" , || async {
444+ while wallet. next_event ( ) . is_some ( ) {
445+ wallet. event_handled ( ) . unwrap ( ) ;
446+ }
447+ starting_lsp_channels. len ( ) < lsp. list_channels ( ) . len ( )
448+ } )
449+ . await ;
450+ // Drain the post-graduation events (ChannelOpened, the self-custodial
451+ // PaymentReceived, RebalanceSuccessful) so the rebalance bookkeeping lands.
452+ // Wait until a graduated receive shows a non-zero fee: that only happens once
453+ // the trigger has been promoted to `PaymentTriggeringTransferLightning` and
454+ // merged with its `TrustedToLightning` leg, which is exactly the display path
455+ // under test. Without this we could read the list before promotion, while the
456+ // receives are still plain (already-settle-timed) trusted payments.
457+ test_utils:: wait_for_condition ( "rebalance bookkeeping settles" , || async {
458+ while wallet. next_event ( ) . is_some ( ) {
459+ wallet. event_handled ( ) . unwrap ( ) ;
460+ }
461+ let incoming: Vec < _ > = wallet
462+ . list_transactions ( )
463+ . await
464+ . unwrap ( )
465+ . into_iter ( )
466+ . filter ( |tx| !tx. outbound )
467+ . collect ( ) ;
468+ incoming. len ( ) == 2
469+ && incoming. iter ( ) . any ( |tx| tx. fee . is_some_and ( |f| f > Amount :: ZERO ) )
470+ } )
471+ . await ;
472+
473+ let txs = wallet. list_transactions ( ) . await . unwrap ( ) ;
474+ let incoming: Vec < _ > = txs. into_iter ( ) . filter ( |tx| !tx. outbound ) . collect ( ) ;
475+ assert_eq ! ( incoming. len( ) , 2 , "Should have exactly 2 incoming transactions" ) ;
476+
477+ // Both receives settled (and were stamped by the backend) before we re-enabled
478+ // rebalancing, so their displayed times must precede the discovery boundary —
479+ // including the receive that graduated into a Lightning channel, which would
480+ // otherwise surface the rebalancer's discovery stamp written at/after
481+ // `enabled_at`.
482+ for tx in & incoming {
483+ assert ! (
484+ tx. time_since_epoch < enabled_at,
485+ "expected backend settle time, got discovery stamp: {:?} >= {:?}" ,
486+ tx. time_since_epoch,
487+ enabled_at
488+ ) ;
489+ }
490+ } )
491+ . await ;
492+ }
493+
494+ #[ tokio:: test( flavor = "multi_thread" ) ]
495+ #[ test_log:: test]
496+ async fn test_onchain_graduated_receive_keeps_confirmation_time ( ) {
497+ test_utils:: run_test ( |params| async move {
498+ let wallet = Arc :: clone ( & params. wallet ) ;
499+ let bitcoind = Arc :: clone ( & params. bitcoind ) ;
500+ let third_party = Arc :: clone ( & params. third_party ) ;
501+ let electrsd = Arc :: clone ( & params. electrsd ) ;
502+
503+ // Disable rebalancing so the on-chain receive confirms (and ldk-node records
504+ // its update/confirmation time) before the rebalancer ever observes it. The
505+ // rebalancer only stamps its discovery `now()` once we re-enable it.
506+ wallet. set_rebalance_enabled ( false ) . await ;
507+
508+ let recv_amt = Amount :: from_sats ( 200_000 ) . unwrap ( ) ;
509+ let uri = wallet. get_single_use_receive_uri ( Some ( recv_amt) ) . await . unwrap ( ) ;
510+ let sent_txid = third_party
511+ . onchain_payment ( )
512+ . send_to_address ( & uri. address . unwrap ( ) , recv_amt. sats ( ) . unwrap ( ) , None )
513+ . unwrap ( ) ;
514+ wait_for_tx ( & electrsd. client , sent_txid) . await ;
515+ generate_blocks ( & bitcoind, & electrsd, 6 ) . await ;
516+ wallet. sync_ln_wallet ( ) . unwrap ( ) ;
517+
518+ // Wait until ldk-node sees the confirmed receive. Its confirmation time is now
519+ // recorded while rebalancing is still disabled (so it carries no metadata and
520+ // renders via the confirmation-time path).
521+ test_utils:: wait_for_condition ( "onchain receive confirmed" , || async {
522+ wallet. get_balance ( ) . await . unwrap ( ) . pending_balance == recv_amt
523+ } )
524+ . await ;
525+ let pre_grad_time = {
526+ let txs = wallet. list_transactions ( ) . await . unwrap ( ) ;
527+ assert_eq ! ( txs. len( ) , 1 ) ;
528+ txs[ 0 ] . time_since_epoch
529+ } ;
530+
531+ // Sleep so any later discovery stamp lands a clearly later wall-clock time,
532+ // then mark that boundary just before we let the rebalancer run.
533+ tokio:: time:: sleep ( Duration :: from_secs ( 3 ) ) . await ;
534+ let enabled_at = SystemTime :: now ( ) . duration_since ( UNIX_EPOCH ) . unwrap ( ) ;
535+
536+ // Re-enable rebalancing; the periodic on-chain rebalance loop now observes the
537+ // receive for the first time (stamping discovery >= enabled_at) and graduates
538+ // it into a channel. Keep mining/syncing so the channel opening confirms, and
539+ // wait until the receive renders via the graduated display path — i.e. it
540+ // carries a rebalance fee, which only appears once the trigger is promoted to
541+ // `PaymentTriggeringTransferLightning` and merged with its channel-open leg.
542+ wallet. set_rebalance_enabled ( true ) . await ;
543+ test_utils:: wait_for_condition ( "graduated onchain receive shows fee" , || async {
544+ while wallet. next_event ( ) . is_some ( ) {
545+ wallet. event_handled ( ) . unwrap ( ) ;
546+ }
547+ generate_blocks ( & bitcoind, & electrsd, 6 ) . await ;
548+ wallet. sync_ln_wallet ( ) . unwrap ( ) ;
549+ wallet
550+ . list_transactions ( )
551+ . await
552+ . unwrap ( )
553+ . iter ( )
554+ . any ( |tx| !tx. outbound && tx. fee . is_some_and ( |f| f > Amount :: ZERO ) )
555+ } )
556+ . await ;
557+
558+ let txs = wallet. list_transactions ( ) . await . unwrap ( ) ;
559+ let incoming: Vec < _ > = txs. into_iter ( ) . filter ( |tx| !tx. outbound ) . collect ( ) ;
560+ assert_eq ! ( incoming. len( ) , 1 , "Should have exactly one incoming transaction" ) ;
561+ let tx = & incoming[ 0 ] ;
562+ assert ! (
563+ matches!( tx. payment_type, PaymentType :: IncomingOnChain { .. } ) ,
564+ "Expected IncomingOnChain, got {:?}" ,
565+ tx. payment_type
566+ ) ;
567+ assert ! (
568+ tx. fee. is_some_and( |f| f > Amount :: ZERO ) ,
569+ "graduated receive should carry a rebalance fee"
570+ ) ;
571+
572+ // The graduated receive must keep its on-chain confirmation time. Before the
573+ // fix it surfaced the rebalancer's `now()` discovery stamp written at/after
574+ // `enabled_at`. We check both that it precedes the discovery boundary and that
575+ // graduation did not change the time the receive already displayed.
576+ assert ! (
577+ tx. time_since_epoch < enabled_at,
578+ "expected onchain confirmation time, got discovery stamp: {:?} >= {:?}" ,
579+ tx. time_since_epoch,
580+ enabled_at
581+ ) ;
582+ assert_eq ! (
583+ tx. time_since_epoch, pre_grad_time,
584+ "graduation should not change the displayed confirmation time"
585+ ) ;
586+ } )
587+ . await ;
588+ }
589+
395590#[ tokio:: test( flavor = "multi_thread" ) ]
396591#[ test_log:: test]
397592async fn test_receive_to_ln ( ) {
0 commit comments