Skip to content

Commit 817208d

Browse files
benthecarmanclaude
andcommitted
Reserve metadata slot at SplicePending/ChannelPending
ldk-node lists the splice or channel funding tx as an outbound on-chain payment the moment it broadcasts, but our tx_metadata entry isn't inserted until the rebalancer's OnChainRebalanceInitiated handler runs. A concurrent list_transactions call sees an outbound payment with no metadata and trips a debug_assert. Reserve a PendingRebalance placeholder at SplicePending and ChannelPending, which list_transactions already skips. Combine the trigger promotion and splice upsert in OnChainRebalanceInitiated into a single write-lock acquisition so the trigger and splice metadata are never visible to a reader in a half-updated state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 12213a8 commit 817208d

5 files changed

Lines changed: 165 additions & 89 deletions

File tree

orange-sdk/src/event.rs

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use crate::logging::Logger;
22
use crate::store::{self, PaymentId};
33

44
use crate::dyn_store::DynStore;
5+
use ldk_node::bitcoin::hashes::Hash;
56
use ldk_node::bitcoin::secp256k1::PublicKey;
67
use ldk_node::bitcoin::{OutPoint, Txid};
78
use ldk_node::lightning::events::{ClosureReason, PaymentFailureReason};
@@ -17,6 +18,7 @@ use ldk_node::{CustomTlvRecord, UserChannelId};
1718
use std::collections::VecDeque;
1819
use std::sync::Arc;
1920
use std::task::{Poll, Waker};
21+
use std::time::SystemTime;
2022
use tokio::sync::{Mutex, watch};
2123

2224
/// The event queue will be persisted under this key.
@@ -324,7 +326,7 @@ pub(crate) struct LdkEventHandler {
324326
pub(crate) tx_metadata: store::TxMetadataStore,
325327
pub(crate) payment_receipt_sender: watch::Sender<()>,
326328
pub(crate) channel_pending_sender: watch::Sender<u128>,
327-
pub(crate) splice_pending_sender: watch::Sender<u128>,
329+
pub(crate) splice_pending_inbox: Arc<crate::lightning_wallet::SplicePendingInbox>,
328330
pub(crate) logger: Arc<Logger>,
329331
}
330332

@@ -410,8 +412,12 @@ impl LdkEventHandler {
410412
"Unexpected PaymentClaimable event received. This is likely due to a bug in the LDK Node implementation."
411413
);
412414
},
413-
ldk_node::Event::ChannelPending { .. } => {
415+
ldk_node::Event::ChannelPending { funding_txo, .. } => {
414416
log_debug!(self.logger, "Received ChannelPending event");
417+
// The funding tx is already in `ldk_node.list_payments()`; populate our
418+
// metadata before any concurrent `list_transactions` call observes the
419+
// outbound payment.
420+
self.reserve_rebalance_slot_for_funding_tx(funding_txo.txid).await;
415421
},
416422
ldk_node::Event::ChannelReady {
417423
channel_id,
@@ -467,7 +473,11 @@ impl LdkEventHandler {
467473
new_funding_txo,
468474
} => {
469475
log_debug!(self.logger, "Received SplicePending event {event:?}");
470-
let _ = self.splice_pending_sender.send(user_channel_id.0);
476+
// Reserve the metadata slot before delivering so any task waking on the
477+
// inbox (the rebalancer's `OnChainRebalanceInitiated` for splice-in,
478+
// `pay_lightning` for splice-out) sees an entry to upsert.
479+
self.reserve_rebalance_slot_for_funding_tx(new_funding_txo.txid).await;
480+
self.splice_pending_inbox.deliver(user_channel_id.0, new_funding_txo);
471481

472482
if let Err(e) = self
473483
.event_queue
@@ -492,4 +502,27 @@ impl LdkEventHandler {
492502
log_error!(self.logger, "Failed to handle event: {e:?}");
493503
}
494504
}
505+
506+
/// Reserve a `PendingRebalance` metadata slot for a freshly broadcast channel or splice
507+
/// funding tx. The matching outbound on-chain payment is already visible in
508+
/// `ldk_node.list_payments()` by the time we're called, so without this entry
509+
/// `list_transactions` would trip its `debug_assert_ne!`. `PendingRebalance` is used as the
510+
/// placeholder because `list_transactions` already skips it.
511+
async fn reserve_rebalance_slot_for_funding_tx(&self, txid: Txid) {
512+
let payment_id = PaymentId::SelfCustodial(txid.to_byte_array());
513+
if self.tx_metadata.read().get(&payment_id).is_some() {
514+
return;
515+
}
516+
self.tx_metadata
517+
.upsert(
518+
payment_id,
519+
store::TxMetadata {
520+
ty: store::TxType::PendingRebalance {},
521+
time: SystemTime::now()
522+
.duration_since(SystemTime::UNIX_EPOCH)
523+
.unwrap_or_default(),
524+
},
525+
)
526+
.await;
527+
}
495528
}

orange-sdk/src/lib.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1459,8 +1459,14 @@ impl Wallet {
14591459
/// Stops the wallet, which will stop the underlying LDK node and any background tasks.
14601460
/// This will ensure that any critical tasks have completed before stopping.
14611461
pub async fn stop(&self) {
1462-
// wait for the balance mutex to ensure no other tasks are running
14631462
log_info!(self.inner.logger, "Stopping...");
1463+
// Abort the background rebalance loop first. If a rebalance is parked in
1464+
// `await_splice_pending` waiting for an event that's never going to arrive
1465+
// (e.g. the LSP went away mid-splice), it's still holding the rebalancer's
1466+
// `balance_mutex` — `rebalancer.stop().await` would deadlock waiting for it.
1467+
// Dropping the task releases the mutex.
1468+
self.inner.runtime.abort_cancellable_background_tasks();
1469+
14641470
log_info!(self.inner.logger, "Stopping rebalancer...");
14651471
let _ = self.inner.rebalancer.stop().await;
14661472

@@ -1470,9 +1476,6 @@ impl Wallet {
14701476
log_debug!(self.inner.logger, "Stopping ln wallet...");
14711477
self.inner.ln_wallet.stop();
14721478

1473-
// Cancel cancellable background tasks
1474-
self.inner.runtime.abort_cancellable_background_tasks();
1475-
14761479
// Wait until non-cancellable background tasks (mod LDK's background processor) are done.
14771480
self.inner.runtime.wait_on_background_tasks();
14781481
}

orange-sdk/src/lightning_wallet.rs

Lines changed: 63 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ use graduated_rebalancer::{LightningBalance, ReceivedLightningPayment};
3030
use std::collections::HashMap;
3131
use std::fmt::Debug;
3232
use std::pin::Pin;
33-
use std::sync::Arc;
33+
use std::sync::{Arc, Mutex};
3434
use std::time::SystemTime;
35-
use tokio::sync::watch;
35+
use tokio::sync::{Notify, watch};
3636

3737
#[derive(Debug, Clone, Copy)]
3838
pub(crate) struct LightningWalletBalance {
@@ -46,11 +46,27 @@ pub(crate) struct LightningWalletImpl {
4646
store: Arc<dyn DynStore>,
4747
payment_receipt_flag: watch::Receiver<()>,
4848
channel_pending_receipt_flag: watch::Receiver<u128>,
49-
splice_pending_receipt_flag: watch::Receiver<u128>,
49+
splice_pending_inbox: Arc<SplicePendingInbox>,
5050
lsp_node_id: PublicKey,
5151
lsp_socket_addr: SocketAddress,
5252
}
5353

54+
/// One pending `SplicePending` event per `user_channel_id`, consumed by
55+
/// `await_splice_pending`. A queue rather than a `watch` so each consumer takes its own event:
56+
/// `watch` would let a second splice on the same channel observe the previous splice's stale
57+
/// outpoint instead of waiting for the new `SplicePending`.
58+
pub(crate) struct SplicePendingInbox {
59+
pub(crate) pending: Mutex<HashMap<u128, OutPoint>>,
60+
pub(crate) notify: Notify,
61+
}
62+
63+
impl SplicePendingInbox {
64+
pub(crate) fn deliver(&self, channel_id: u128, funding_txo: OutPoint) {
65+
self.pending.lock().unwrap().insert(channel_id, funding_txo);
66+
self.notify.notify_waiters();
67+
}
68+
}
69+
5470
pub(crate) struct LightningWallet {
5571
pub(crate) inner: Arc<LightningWalletImpl>,
5672
}
@@ -176,14 +192,17 @@ impl LightningWallet {
176192
Arc::new(builder.build_with_store(node_entropy, LdkNodeStore(Arc::clone(&store)))?);
177193
let (payment_receipt_sender, payment_receipt_flag) = watch::channel(());
178194
let (channel_pending_sender, channel_pending_receipt_flag) = watch::channel(0);
179-
let (splice_pending_sender, splice_pending_receipt_flag) = watch::channel(0);
195+
let splice_pending_inbox = Arc::new(SplicePendingInbox {
196+
pending: Mutex::new(HashMap::new()),
197+
notify: Notify::new(),
198+
});
180199
let ev_handler = Arc::new(LdkEventHandler {
181200
event_queue,
182201
ldk_node: Arc::clone(&ldk_node),
183202
tx_metadata,
184203
payment_receipt_sender,
185204
channel_pending_sender,
186-
splice_pending_sender,
205+
splice_pending_inbox: Arc::clone(&splice_pending_inbox),
187206
logger: Arc::clone(&logger),
188207
});
189208
let inner = Arc::new(LightningWalletImpl {
@@ -192,7 +211,7 @@ impl LightningWallet {
192211
store,
193212
payment_receipt_flag,
194213
channel_pending_receipt_flag,
195-
splice_pending_receipt_flag,
214+
splice_pending_inbox,
196215
lsp_node_id,
197216
lsp_socket_addr,
198217
});
@@ -222,10 +241,19 @@ impl LightningWallet {
222241
flag.wait_for(|t| t == &channel_id).await.expect("channel pending not received");
223242
}
224243

225-
pub(crate) async fn await_splice_pending(&self, channel_id: u128) {
226-
let mut flag = self.inner.splice_pending_receipt_flag.clone();
227-
flag.mark_unchanged();
228-
flag.wait_for(|t| t == &channel_id).await.expect("splice pending not received");
244+
pub(crate) async fn await_splice_pending(&self, channel_id: u128) -> OutPoint {
245+
let inbox = &self.inner.splice_pending_inbox;
246+
loop {
247+
// Register interest BEFORE checking the queue so a `notify_waiters` racing with the
248+
// check still wakes us up.
249+
let notified = inbox.notify.notified();
250+
tokio::pin!(notified);
251+
notified.as_mut().enable();
252+
if let Some(txo) = inbox.pending.lock().unwrap().remove(&channel_id) {
253+
return txo;
254+
}
255+
notified.await;
256+
}
229257
}
230258

231259
pub(crate) fn get_on_chain_address(&self) -> Result<Address, NodeError> {
@@ -343,53 +371,28 @@ impl LightningWallet {
343371
amount_sats,
344372
)?;
345373

346-
loop {
374+
let funding_txo =
347375
self.await_splice_pending(chan.user_channel_id.0).await;
348-
let channels = self.inner.ldk_node.list_channels();
349-
let new_chan = channels
350-
.iter()
351-
.find(|c| c.user_channel_id == chan.user_channel_id);
352-
match new_chan {
353-
Some(c) => {
354-
if c.funding_txo
355-
.is_some_and(|f| f != chan.funding_txo.unwrap())
356-
{
357-
let funding_txo = c.funding_txo.unwrap();
358-
359-
let id = PaymentId(funding_txo.txid.to_byte_array());
360-
let details = PaymentDetails {
361-
id,
362-
kind: PaymentKind::Onchain {
363-
txid: funding_txo.txid,
364-
status: ConfirmationStatus::Unconfirmed, // todo how do we update this?
365-
},
366-
amount_msat: Some(amount_sats * 1_000),
367-
fee_paid_msat: Some(69), // todo get real fee
368-
direction: PaymentDirection::Outbound,
369-
status: PaymentStatus::Succeeded,
370-
latest_update_timestamp: SystemTime::now()
371-
.duration_since(SystemTime::UNIX_EPOCH)
372-
.unwrap()
373-
.as_secs(),
374-
};
375-
376-
store::write_splice_out(
377-
self.inner.store.as_ref(),
378-
&details,
379-
)
380-
.await;
381-
return Ok(id);
382-
}
383-
},
384-
None => {
385-
log_error!(
386-
self.inner.logger,
387-
"Channel disappeared while awaiting splice out"
388-
);
389-
return Err(NodeError::WalletOperationFailed);
390-
},
391-
}
392-
}
376+
377+
let id = PaymentId(funding_txo.txid.to_byte_array());
378+
let details = PaymentDetails {
379+
id,
380+
kind: PaymentKind::Onchain {
381+
txid: funding_txo.txid,
382+
status: ConfirmationStatus::Unconfirmed, // todo how do we update this?
383+
},
384+
amount_msat: Some(amount_sats * 1_000),
385+
fee_paid_msat: Some(69), // todo get real fee
386+
direction: PaymentDirection::Outbound,
387+
status: PaymentStatus::Succeeded,
388+
latest_update_timestamp: SystemTime::now()
389+
.duration_since(SystemTime::UNIX_EPOCH)
390+
.unwrap()
391+
.as_secs(),
392+
};
393+
394+
store::write_splice_out(self.inner.store.as_ref(), &details).await;
395+
Ok(id)
393396
},
394397
}
395398
}
@@ -587,26 +590,10 @@ impl graduated_rebalancer::LightningWallet for LightningWallet {
587590
fn await_splice_pending(
588591
&self, channel_id: u128,
589592
) -> Pin<Box<dyn Future<Output = OutPoint> + Send + '_>> {
590-
Box::pin(async move {
591-
// todo since we can't see if we have any active splices, we just await the next splice pending event
592-
// this is kinda race-y hopefully we can fix
593-
self.await_splice_pending(channel_id).await;
594-
loop {
595-
let channels = self.inner.ldk_node.list_channels();
596-
let chan = channels
597-
.into_iter()
598-
.find(|c| c.user_channel_id.0 == channel_id && c.funding_txo.is_some());
599-
match chan {
600-
Some(c) => {
601-
return c.funding_txo.expect("channel has no funding txo");
602-
},
603-
None => {
604-
self.await_splice_pending(channel_id).await;
605-
// Wait for the next channel pending event
606-
},
607-
}
608-
}
609-
})
593+
// `ChannelDetails.funding_txo` from `list_channels` still reports the old funding
594+
// outpoint between `SplicePending` and `SpliceLocked`, so we return the new outpoint
595+
// from the event itself rather than reading it back from the channel.
596+
Box::pin(async move { self.await_splice_pending(channel_id).await })
610597
}
611598
}
612599

orange-sdk/src/rebalancer.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -361,17 +361,18 @@ impl graduated_rebalancer::EventHandler for OrangeRebalanceEventHandler {
361361
let chan_txid = channel_outpoint.txid;
362362
let triggering_txid = Txid::from_byte_array(trigger_id);
363363
let trigger_id = PaymentId::SelfCustodial(triggering_txid.to_byte_array());
364-
self.tx_metadata
365-
.set_tx_caused_rebalance(&trigger_id)
366-
.await
367-
.expect("Failed to write metadata for onchain rebalance transaction");
368364
let metadata = TxMetadata {
369365
ty: TxType::OnchainToLightning { channel_txid: chan_txid, triggering_txid },
370366
time: SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap(),
371367
};
372368
self.tx_metadata
373-
.insert(PaymentId::SelfCustodial(chan_txid.to_byte_array()), metadata)
374-
.await;
369+
.set_tx_caused_rebalance_with_splice(
370+
&trigger_id,
371+
PaymentId::SelfCustodial(chan_txid.to_byte_array()),
372+
metadata,
373+
)
374+
.await
375+
.expect("Failed to write metadata for onchain rebalance transaction");
375376
},
376377
}
377378
})

orange-sdk/src/store.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,58 @@ impl TxMetadataStore {
368368
Ok(())
369369
}
370370

371+
/// Atomically marks `trigger_id` as a rebalance trigger and writes `splice_metadata` for
372+
/// `splice_id`. The in-memory updates happen under a single write lock so that a concurrent
373+
/// `list_transactions` either sees both changes or neither — without this, the rebalancer
374+
/// briefly exposes a state where the trigger has been promoted to
375+
/// `PaymentTriggeringTransferLightning` but the matching `OnchainToLightning` splice entry
376+
/// hasn't landed yet, which makes the `InternalTransfer` validation in `list_transactions`
377+
/// trip on a missing `send_fee`.
378+
pub async fn set_tx_caused_rebalance_with_splice(
379+
&self, trigger_id: &PaymentId, splice_id: PaymentId, splice_metadata: TxMetadata,
380+
) -> Result<(), ()> {
381+
let (trigger_entry, splice_entry) = {
382+
let mut tx_metadata = self.tx_metadata.write().unwrap();
383+
let trigger = match tx_metadata.get_mut(trigger_id) {
384+
Some(metadata) => {
385+
if let TxType::Payment { ty } = &mut metadata.ty {
386+
metadata.ty = TxType::PaymentTriggeringTransferLightning { ty: *ty };
387+
(trigger_id.to_string(), metadata.encode())
388+
} else {
389+
eprintln!("payment_id {trigger_id} is not a payment, cannot set rebalance");
390+
return Err(());
391+
}
392+
},
393+
None => {
394+
eprintln!("doesn't exist in metadata store: {trigger_id}");
395+
return Err(());
396+
},
397+
};
398+
tx_metadata.insert(splice_id, splice_metadata);
399+
let splice = (splice_id.to_string(), splice_metadata.encode());
400+
(trigger, splice)
401+
};
402+
let (trigger_res, splice_res) = tokio::join!(
403+
KVStore::write(
404+
self.store.as_ref(),
405+
STORE_PRIMARY_KEY,
406+
STORE_SECONDARY_KEY,
407+
&trigger_entry.0,
408+
trigger_entry.1,
409+
),
410+
KVStore::write(
411+
self.store.as_ref(),
412+
STORE_PRIMARY_KEY,
413+
STORE_SECONDARY_KEY,
414+
&splice_entry.0,
415+
splice_entry.1,
416+
),
417+
);
418+
trigger_res.expect("We do not allow writes to fail");
419+
splice_res.expect("We do not allow writes to fail");
420+
Ok(())
421+
}
422+
371423
/// Sets the preimage for an outgoing lightning payment. If the payment already has a preimage,
372424
/// this is a no-op and returns Ok(()). If the payment_id does not exist in the store, or if the payment
373425
/// is not an outgoing lightning payment, returns Err(()).

0 commit comments

Comments
 (0)