Skip to content

Commit 8fc86b9

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 8fc86b9

4 files changed

Lines changed: 128 additions & 79 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_sender: watch::Sender<Option<(u128, OutPoint)>>,
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 signalling so that any task waking on
477+
// `splice_pending_sender` (the rebalancer's `OnChainRebalanceInitiated`
478+
// for splice-in, `pay_lightning` for splice-out) sees an entry to upsert.
479+
self.reserve_rebalance_slot_for_funding_tx(new_funding_txo.txid).await;
480+
let _ = self.splice_pending_sender.send(Some((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/lightning_wallet.rs

Lines changed: 33 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ 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_receipt_flag: watch::Receiver<Option<(u128, OutPoint)>>,
5050
lsp_node_id: PublicKey,
5151
lsp_socket_addr: SocketAddress,
5252
}
@@ -176,7 +176,7 @@ impl LightningWallet {
176176
Arc::new(builder.build_with_store(node_entropy, LdkNodeStore(Arc::clone(&store)))?);
177177
let (payment_receipt_sender, payment_receipt_flag) = watch::channel(());
178178
let (channel_pending_sender, channel_pending_receipt_flag) = watch::channel(0);
179-
let (splice_pending_sender, splice_pending_receipt_flag) = watch::channel(0);
179+
let (splice_pending_sender, splice_pending_receipt_flag) = watch::channel(None);
180180
let ev_handler = Arc::new(LdkEventHandler {
181181
event_queue,
182182
ldk_node: Arc::clone(&ldk_node),
@@ -222,10 +222,14 @@ impl LightningWallet {
222222
flag.wait_for(|t| t == &channel_id).await.expect("channel pending not received");
223223
}
224224

225-
pub(crate) async fn await_splice_pending(&self, channel_id: u128) {
225+
pub(crate) async fn await_splice_pending(&self, channel_id: u128) -> OutPoint {
226226
let mut flag = self.inner.splice_pending_receipt_flag.clone();
227227
flag.mark_unchanged();
228-
flag.wait_for(|t| t == &channel_id).await.expect("splice pending not received");
228+
let got = flag
229+
.wait_for(|t| matches!(t, Some((id, _)) if *id == channel_id))
230+
.await
231+
.expect("splice pending not received");
232+
got.expect("matched value is Some").1
229233
}
230234

231235
pub(crate) fn get_on_chain_address(&self) -> Result<Address, NodeError> {
@@ -343,53 +347,28 @@ impl LightningWallet {
343347
amount_sats,
344348
)?;
345349

346-
loop {
350+
let funding_txo =
347351
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-
}
352+
353+
let id = PaymentId(funding_txo.txid.to_byte_array());
354+
let details = PaymentDetails {
355+
id,
356+
kind: PaymentKind::Onchain {
357+
txid: funding_txo.txid,
358+
status: ConfirmationStatus::Unconfirmed, // todo how do we update this?
359+
},
360+
amount_msat: Some(amount_sats * 1_000),
361+
fee_paid_msat: Some(69), // todo get real fee
362+
direction: PaymentDirection::Outbound,
363+
status: PaymentStatus::Succeeded,
364+
latest_update_timestamp: SystemTime::now()
365+
.duration_since(SystemTime::UNIX_EPOCH)
366+
.unwrap()
367+
.as_secs(),
368+
};
369+
370+
store::write_splice_out(self.inner.store.as_ref(), &details).await;
371+
Ok(id)
393372
},
394373
}
395374
}
@@ -587,26 +566,10 @@ impl graduated_rebalancer::LightningWallet for LightningWallet {
587566
fn await_splice_pending(
588567
&self, channel_id: u128,
589568
) -> 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-
})
569+
// `ChannelDetails.funding_txo` from `list_channels` still reports the old funding
570+
// outpoint between `SplicePending` and `SpliceLocked`, so we return the new outpoint
571+
// from the event itself rather than reading it back from the channel.
572+
Box::pin(async move { self.await_splice_pending(channel_id).await })
610573
}
611574
}
612575

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)