From 91ca7b8411157d70016bca8b9e2c86d96dbb2a0e Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 26 Jun 2026 16:55:50 -0500 Subject: [PATCH] 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) --- orange-sdk/src/lib.rs | 4 + orange-sdk/src/rebalancer.rs | 18 ++- orange-sdk/tests/integration_tests.rs | 195 ++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 7 deletions(-) diff --git a/orange-sdk/src/lib.rs b/orange-sdk/src/lib.rs index 8ed477c..81e637b 100644 --- a/orange-sdk/src/lib.rs +++ b/orange-sdk/src/lib.rs @@ -831,6 +831,8 @@ impl Wallet { amount: Some(payment.amount), fee: Some(payment.fee), payment_type: *ty, + // Graduated receive: `tx_metadata.time` is the backend settle time + // the rebalancer stamped on first observing it, kept through promotion. time_since_epoch: tx_metadata.time, }); }, @@ -972,6 +974,8 @@ impl Wallet { .map(|a| Amount::from_milli_sats(a).expect("Must be valid")), fee, payment_type: (&payment).into(), + // Graduated on-chain receive: `tx_metadata.time` is ldk-node's + // confirmation time, stamped on first observing it, kept through promotion. time_since_epoch: tx_metadata.time, }); }, diff --git a/orange-sdk/src/rebalancer.rs b/orange-sdk/src/rebalancer.rs index 0d6e7a8..cad0cdb 100644 --- a/orange-sdk/src/rebalancer.rs +++ b/orange-sdk/src/rebalancer.rs @@ -104,9 +104,10 @@ impl RebalanceTrigger for OrangeTrigger { payment_id, TxMetadata { ty: TxType::Payment { ty: PaymentType::IncomingLightning {} }, - time: SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap(), + // Backend settle time, not detection time, so a receive that + // settled while offline keeps its real time. Preserved through + // promotion for the graduated-transfer display. + time: payment.time_since_epoch, }, ) .await; @@ -226,7 +227,7 @@ impl RebalanceTrigger for OrangeTrigger { }) .max_by_key(|(t, _, _)| t.amount_msat); match new { - Some((_, txid, trigger)) => { + Some((payment, txid, trigger)) => { // make sure we have a metadata entry for the triggering transaction if self.tx_metadata.read().get(&trigger).is_none() { self.tx_metadata @@ -238,9 +239,12 @@ impl RebalanceTrigger for OrangeTrigger { txid: Some(txid), }, }, - time: SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap(), + // ldk-node's confirmation time, not detection time, so a + // receive that confirmed while offline keeps its real + // time. Preserved through promotion for display. + time: Duration::from_secs( + payment.latest_update_timestamp, + ), }, ) .await; diff --git a/orange-sdk/tests/integration_tests.rs b/orange-sdk/tests/integration_tests.rs index abbea45..b432052 100644 --- a/orange-sdk/tests/integration_tests.rs +++ b/orange-sdk/tests/integration_tests.rs @@ -392,6 +392,201 @@ async fn test_sweep_to_ln() { .await; } +#[tokio::test(flavor = "multi_thread")] +#[test_log::test] +async fn test_graduated_transfer_keeps_backend_settle_time() { + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let lsp = Arc::clone(¶ms.lsp); + let third_party = Arc::clone(¶ms.third_party); + + let starting_lsp_channels = lsp.list_channels(); + + // Disable rebalancing so both receives settle in the trusted wallet before + // the rebalancer ever observes them. This way the only thing that stamps a + // discovery time is the rebalancer run we trigger later. + wallet.set_rebalance_enabled(false).await; + + let limit = wallet.get_tunables(); + let first_amt = + Amount::from_milli_sats(limit.trusted_balance_limit.milli_sats() / 2).unwrap(); + let second_amt = Amount::from_milli_sats(limit.trusted_balance_limit.milli_sats()).unwrap(); + + let uri = wallet.get_single_use_receive_uri(Some(first_amt)).await.unwrap(); + assert!(uri.from_trusted); + third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + test_utils::wait_for_condition("first receive", || async { + wallet.get_balance().await.unwrap().available_balance() >= first_amt + }) + .await; + + let uri = wallet.get_single_use_receive_uri(Some(second_amt)).await.unwrap(); + assert!(uri.from_trusted); + third_party.bolt11_payment().send(&uri.invoice, None).unwrap(); + test_utils::wait_for_condition("second receive", || async { + wallet.get_balance().await.unwrap().available_balance() + >= first_amt.saturating_add(second_amt) + }) + .await; + + // The backend recorded both settle times ~now. Sleep so any later discovery + // stamp lands a clearly later wall-clock time, then mark that boundary just + // before we let the rebalancer run. + tokio::time::sleep(Duration::from_secs(3)).await; + let enabled_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); + + // Re-enable rebalancing and start draining events. Marking each event handled + // re-triggers the rebalancer, which observes the payments for the first time + // (stamping discovery times >= enabled_at) and graduates the trusted balance + // into a Lightning channel. + wallet.set_rebalance_enabled(true).await; + test_utils::wait_for_condition("new channel opened", || async { + while wallet.next_event().is_some() { + wallet.event_handled().unwrap(); + } + starting_lsp_channels.len() < lsp.list_channels().len() + }) + .await; + // Drain the post-graduation events (ChannelOpened, the self-custodial + // PaymentReceived, RebalanceSuccessful) so the rebalance bookkeeping lands. + // Wait until a graduated receive shows a non-zero fee: that only happens once + // the trigger has been promoted to `PaymentTriggeringTransferLightning` and + // merged with its `TrustedToLightning` leg, which is exactly the display path + // under test. Without this we could read the list before promotion, while the + // receives are still plain (already-settle-timed) trusted payments. + test_utils::wait_for_condition("rebalance bookkeeping settles", || async { + while wallet.next_event().is_some() { + wallet.event_handled().unwrap(); + } + let incoming: Vec<_> = wallet + .list_transactions() + .await + .unwrap() + .into_iter() + .filter(|tx| !tx.outbound) + .collect(); + incoming.len() == 2 + && incoming.iter().any(|tx| tx.fee.is_some_and(|f| f > Amount::ZERO)) + }) + .await; + + let txs = wallet.list_transactions().await.unwrap(); + let incoming: Vec<_> = txs.into_iter().filter(|tx| !tx.outbound).collect(); + assert_eq!(incoming.len(), 2, "Should have exactly 2 incoming transactions"); + + // Both receives settled (and were stamped by the backend) before we re-enabled + // rebalancing, so their displayed times must precede the discovery boundary — + // including the receive that graduated into a Lightning channel, which would + // otherwise surface the rebalancer's discovery stamp written at/after + // `enabled_at`. + for tx in &incoming { + assert!( + tx.time_since_epoch < enabled_at, + "expected backend settle time, got discovery stamp: {:?} >= {:?}", + tx.time_since_epoch, + enabled_at + ); + } + }) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +#[test_log::test] +async fn test_onchain_graduated_receive_keeps_confirmation_time() { + test_utils::run_test(|params| async move { + let wallet = Arc::clone(¶ms.wallet); + let bitcoind = Arc::clone(¶ms.bitcoind); + let third_party = Arc::clone(¶ms.third_party); + let electrsd = Arc::clone(¶ms.electrsd); + + // Disable rebalancing so the on-chain receive confirms (and ldk-node records + // its update/confirmation time) before the rebalancer ever observes it. The + // rebalancer only stamps its discovery `now()` once we re-enable it. + wallet.set_rebalance_enabled(false).await; + + let recv_amt = Amount::from_sats(200_000).unwrap(); + let uri = wallet.get_single_use_receive_uri(Some(recv_amt)).await.unwrap(); + let sent_txid = third_party + .onchain_payment() + .send_to_address(&uri.address.unwrap(), recv_amt.sats().unwrap(), None) + .unwrap(); + wait_for_tx(&electrsd.client, sent_txid).await; + generate_blocks(&bitcoind, &electrsd, 6).await; + wallet.sync_ln_wallet().unwrap(); + + // Wait until ldk-node sees the confirmed receive. Its confirmation time is now + // recorded while rebalancing is still disabled (so it carries no metadata and + // renders via the confirmation-time path). + test_utils::wait_for_condition("onchain receive confirmed", || async { + wallet.get_balance().await.unwrap().pending_balance == recv_amt + }) + .await; + let pre_grad_time = { + let txs = wallet.list_transactions().await.unwrap(); + assert_eq!(txs.len(), 1); + txs[0].time_since_epoch + }; + + // Sleep so any later discovery stamp lands a clearly later wall-clock time, + // then mark that boundary just before we let the rebalancer run. + tokio::time::sleep(Duration::from_secs(3)).await; + let enabled_at = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); + + // Re-enable rebalancing; the periodic on-chain rebalance loop now observes the + // receive for the first time (stamping discovery >= enabled_at) and graduates + // it into a channel. Keep mining/syncing so the channel opening confirms, and + // wait until the receive renders via the graduated display path — i.e. it + // carries a rebalance fee, which only appears once the trigger is promoted to + // `PaymentTriggeringTransferLightning` and merged with its channel-open leg. + wallet.set_rebalance_enabled(true).await; + test_utils::wait_for_condition("graduated onchain receive shows fee", || async { + while wallet.next_event().is_some() { + wallet.event_handled().unwrap(); + } + generate_blocks(&bitcoind, &electrsd, 6).await; + wallet.sync_ln_wallet().unwrap(); + wallet + .list_transactions() + .await + .unwrap() + .iter() + .any(|tx| !tx.outbound && tx.fee.is_some_and(|f| f > Amount::ZERO)) + }) + .await; + + let txs = wallet.list_transactions().await.unwrap(); + let incoming: Vec<_> = txs.into_iter().filter(|tx| !tx.outbound).collect(); + assert_eq!(incoming.len(), 1, "Should have exactly one incoming transaction"); + let tx = &incoming[0]; + assert!( + matches!(tx.payment_type, PaymentType::IncomingOnChain { .. }), + "Expected IncomingOnChain, got {:?}", + tx.payment_type + ); + assert!( + tx.fee.is_some_and(|f| f > Amount::ZERO), + "graduated receive should carry a rebalance fee" + ); + + // The graduated receive must keep its on-chain confirmation time. Before the + // fix it surfaced the rebalancer's `now()` discovery stamp written at/after + // `enabled_at`. We check both that it precedes the discovery boundary and that + // graduation did not change the time the receive already displayed. + assert!( + tx.time_since_epoch < enabled_at, + "expected onchain confirmation time, got discovery stamp: {:?} >= {:?}", + tx.time_since_epoch, + enabled_at + ); + assert_eq!( + tx.time_since_epoch, pre_grad_time, + "graduation should not change the displayed confirmation time" + ); + }) + .await; +} + #[tokio::test(flavor = "multi_thread")] #[test_log::test] async fn test_receive_to_ln() {