Skip to content

Commit 91ca7b8

Browse files
benthecarmanclaude
andcommitted
Stamp real settle time for graduated receives
The transaction list rendered receives that graduated into Lightning using the rebalancer's detection-time stamp (SystemTime::now()), recorded when it first observed the payment. Receives discovered in a single sync pass therefore all displayed an identical time, and a receive that settled while offline floated to the top of history. Fix this at the source: when the rebalancer first observes a receive, stamp the authoritative time into its metadata instead of now() -- the backend's settle time for trusted receives and ldk-node's update (confirmation) time for on-chain ones. That time is preserved through rebalance promotion, so the graduated-transfer display reads it directly and the per-branch read-side overrides are no longer needed. This also corrects a non-graduated on-chain receive that the rebalancer observed but did not graduate, which previously surfaced detection time. Outbound sends keep their initiation time, as they are observed live. Add regression tests for both graduated paths, each forcing a gap between the real settle time and the rebalancer's discovery stamp. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent be9d64f commit 91ca7b8

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)