Skip to content

Commit e9ff424

Browse files
authored
Merge pull request #81 from lightningdevkit/more-time-fixes
Show settle time for graduated receives
2 parents be9d64f + 91ca7b8 commit e9ff424

3 files changed

Lines changed: 210 additions & 7 deletions

File tree

orange-sdk/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -831,6 +831,8 @@ impl Wallet {
831831
amount: Some(payment.amount),
832832
fee: Some(payment.fee),
833833
payment_type: *ty,
834+
// Graduated receive: `tx_metadata.time` is the backend settle time
835+
// the rebalancer stamped on first observing it, kept through promotion.
834836
time_since_epoch: tx_metadata.time,
835837
});
836838
},
@@ -972,6 +974,8 @@ impl Wallet {
972974
.map(|a| Amount::from_milli_sats(a).expect("Must be valid")),
973975
fee,
974976
payment_type: (&payment).into(),
977+
// Graduated on-chain receive: `tx_metadata.time` is ldk-node's
978+
// confirmation time, stamped on first observing it, kept through promotion.
975979
time_since_epoch: tx_metadata.time,
976980
});
977981
},

orange-sdk/src/rebalancer.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,10 @@ impl RebalanceTrigger for OrangeTrigger {
104104
payment_id,
105105
TxMetadata {
106106
ty: TxType::Payment { ty: PaymentType::IncomingLightning {} },
107-
time: SystemTime::now()
108-
.duration_since(SystemTime::UNIX_EPOCH)
109-
.unwrap(),
107+
// Backend settle time, not detection time, so a receive that
108+
// settled while offline keeps its real time. Preserved through
109+
// promotion for the graduated-transfer display.
110+
time: payment.time_since_epoch,
110111
},
111112
)
112113
.await;
@@ -226,7 +227,7 @@ impl RebalanceTrigger for OrangeTrigger {
226227
})
227228
.max_by_key(|(t, _, _)| t.amount_msat);
228229
match new {
229-
Some((_, txid, trigger)) => {
230+
Some((payment, txid, trigger)) => {
230231
// make sure we have a metadata entry for the triggering transaction
231232
if self.tx_metadata.read().get(&trigger).is_none() {
232233
self.tx_metadata
@@ -238,9 +239,12 @@ impl RebalanceTrigger for OrangeTrigger {
238239
txid: Some(txid),
239240
},
240241
},
241-
time: SystemTime::now()
242-
.duration_since(SystemTime::UNIX_EPOCH)
243-
.unwrap(),
242+
// ldk-node's confirmation time, not detection time, so a
243+
// receive that confirmed while offline keeps its real
244+
// time. Preserved through promotion for display.
245+
time: Duration::from_secs(
246+
payment.latest_update_timestamp,
247+
),
244248
},
245249
)
246250
.await;

orange-sdk/tests/integration_tests.rs

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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]
397592
async fn test_receive_to_ln() {

0 commit comments

Comments
 (0)