Skip to content

Commit 9e77526

Browse files
committed
fixup! Use ldk-node payment pagination
Expose the claimable amount so subscribers can validate held payments. Document event delivery limits, claim deadlines, and the current amount check. Share channel count calculations and event acknowledgements. AI assistance: OpenAI Codex was used for this change.
1 parent 95446cc commit 9e77526

8 files changed

Lines changed: 102 additions & 47 deletions

File tree

docs/api-guide.md

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -216,8 +216,21 @@ See [Pagination](#pagination) below for how to page through results.
216216
| `SpliceNegotiated` | A channel splice was negotiated and the funding transaction is pending confirmation |
217217
| `SpliceNegotiationFailed` | A channel splice negotiation round failed |
218218

219-
Events are broadcast to all connected subscribers. The server uses a bounded broadcast channel
220-
(capacity 1024). A slow subscriber that falls behind will miss events.
219+
> [!WARNING]
220+
> `SubscribeEvents` is a best-effort stream of new events. Events are not persisted for
221+
> subscribers, cannot be replayed after reconnecting, and have no client acknowledgement.
222+
> Acceptance by the server's broadcast channel does not guarantee that a client received or
223+
> processed an event.
224+
225+
Events are broadcast to all currently connected subscribers. The server uses a bounded broadcast
226+
channel (capacity 1024), so a slow subscriber that falls behind will miss events. Disconnected
227+
clients also miss events and receive only new events after reconnecting. If the server cannot read
228+
data required to construct a payment event, it logs the error and skips that event so the event
229+
queue can continue processing.
230+
231+
Use events as notifications. After reconnecting, reconcile recoverable state with APIs such as
232+
`GetPaymentDetails`, `ListPayments`, `ListForwardedPayments`, and `ListChannels`. Some event fields
233+
cannot be recovered through these APIs.
221234

222235
### Metrics
223236

@@ -236,7 +249,9 @@ Subscribe with `SubscribeEvents` before you send a BOLT 12 payment. Events are n
236249

237250
When `PaymentSuccessful` arrives, retain its `payment_id`, `payment_preimage`, and
238251
`bolt12_invoice`. Pass these values to `Bolt12CreatePayerProof`. The request can also select the
239-
optional invoice fields that the proof discloses.
252+
optional invoice fields that the proof discloses. Payment history APIs cannot recover all the
253+
inputs required to create a proof if this event is missed. Save these values before processing
254+
other events.
240255

241256
The `bolt12_invoice` field is absent for static-invoice payments. These asynchronous payments
242257
cannot produce payer proofs.
@@ -252,13 +267,22 @@ Hodl invoices allow you to inspect and conditionally accept incoming payments:
252267
3. **Handle each payment:** Save the payment ID from each `PaymentClaimable` event. A payer can pay
253268
the same invoice more than once. Each payment has a separate event and payment ID.
254269
4. **Decide before `claim_deadline`:**
255-
- **Accept an expected payment:** Call `Bolt11ClaimForId` with its payment ID and preimage.
270+
- **Accept an expected payment:** Check the event's `claimable_amount_msat` against the amount
271+
you expect. Call `Bolt11ClaimForId` with its payment ID, preimage, and the event's claimable
272+
amount.
256273
- **Reject an unexpected payment:** Call `Bolt11FailForId` with its payment ID. Reject duplicate
257274
and late payments instead of ignoring or claiming them.
258275

259-
The payment is held in a pending state until you explicitly claim or fail it. **You must
260-
always handle each event.** If you do not, the HTLC will eventually time out. This can cause a
261-
force-closure of the channel.
276+
The claim request's optional amount is passed to LDK Node for a lower-bound check against its
277+
stored payment amount, less any skimmed fee. It is not an exact amount check or a request to claim
278+
that many millisatoshis. A larger supplied amount passes this check; omitting it skips the check.
279+
Always validate the event's amount before you claim the payment.
280+
281+
The payment is held in a pending state until you claim it, fail it, or its `claim_deadline` is
282+
reached. `PaymentClaimable` notifications are best-effort and are not replayed. If you miss the
283+
event or do not act before the deadline, LDK Node automatically fails the HTLC backward and the
284+
payment can no longer be claimed. Keep the subscriber healthy and resolve reported persistence
285+
errors before accepting further payments.
262286

263287
## Pagination
264288

ldk-server-cli/src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ enum Commands {
172172
#[arg(
173173
short,
174174
long,
175-
help = "The claimable amount, e.g. 50sat or 50000msat, only used for verifying we are claiming the expected amount"
175+
help = "The amount from PaymentClaimable, e.g. 50sat or 50000msat. Used for a lower-bound check, not an exact amount check; validate the event amount before claiming"
176176
)]
177177
claimable_amount: Option<Amount>,
178178
},

ldk-server-grpc/src/api.rs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,8 +256,11 @@ pub struct Bolt11ClaimForIdRequest {
256256
/// The hex-encoded 32-byte payment ID from `PaymentClaimable`.
257257
#[prost(string, tag = "1")]
258258
pub payment_id: ::prost::alloc::string::String,
259-
/// The amount in millisatoshi that is claimable.
260-
/// If not provided, skips amount verification.
259+
/// The claimable amount in millisatoshis from the PaymentClaimable event.
260+
/// LDK Node rejects a value below its stored payment amount, less any skimmed fee.
261+
/// A larger value passes this check. This is not an exact amount check or a request
262+
/// to claim that many millisatoshis. Validate the event's amount before claiming.
263+
/// If not provided, skips this amount check.
261264
#[prost(uint64, optional, tag = "2")]
262265
pub claimable_amount_msat: ::core::option::Option<u64>,
263266
/// The hex-encoded 32-byte payment preimage.
@@ -1419,7 +1422,15 @@ pub struct DecodeOfferResponse {
14191422
#[prost(bool, tag = "12")]
14201423
pub is_expired: bool,
14211424
}
1422-
/// Subscribe to a stream of server events.
1425+
/// Subscribe to a best-effort stream of new server events.
1426+
///
1427+
/// Events are not persisted for subscribers or replayed after reconnecting, and the server does not
1428+
/// wait for client acknowledgement. Slow or disconnected subscribers may miss events. Reconcile
1429+
/// recoverable state with the listing and detail APIs after reconnecting. Some event fields,
1430+
/// including inputs required for payer proofs, cannot be recovered through these APIs.
1431+
///
1432+
/// If a PaymentClaimable event is missed and the payment is not otherwise claimed or failed, LDK
1433+
/// Node automatically fails the HTLC backward at its claim_deadline.
14231434
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14241435
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
14251436
#[cfg_attr(feature = "serde", serde(default))]

ldk-server-grpc/src/events.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,9 @@ pub struct PaymentFailed {
244244
/// This event is only emitted for payments created via `Bolt11ReceiveForHash`.
245245
/// Handle every event by its payment ID before `claim_deadline`.
246246
/// The same invoice can produce more than one event. Fail unexpected duplicate or late payments.
247+
/// Delivery through SubscribeEvents is best-effort and is not replayed. If the event is missed and
248+
/// the payment is not otherwise claimed or failed, LDK Node automatically fails the HTLC backward at
249+
/// claim_deadline.
247250
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
248251
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
249252
#[cfg_attr(feature = "serde", serde(default))]
@@ -262,6 +265,10 @@ pub struct PaymentClaimable {
262265
/// The block height by which this payment must be claimed before it is failed back.
263266
#[prost(uint32, optional, tag = "4")]
264267
pub claim_deadline: ::core::option::Option<u32>,
268+
/// The amount in millisatoshis that is claimable. Validate this against the amount you expect
269+
/// before calling Bolt11ClaimForId, and pass this value as its claimable_amount_msat.
270+
#[prost(uint64, tag = "5")]
271+
pub claimable_amount_msat: u64,
265272
}
266273
/// PaymentForwarded indicates a payment was forwarded through the node.
267274
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]

ldk-server-grpc/src/proto/api.proto

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,11 @@ message Bolt11ClaimForIdRequest {
200200
// The hex-encoded 32-byte payment ID from `PaymentClaimable`.
201201
string payment_id = 1;
202202

203-
// The amount in millisatoshi that is claimable.
204-
// If not provided, skips amount verification.
203+
// The claimable amount in millisatoshis from the PaymentClaimable event.
204+
// LDK Node rejects a value below its stored payment amount, less any skimmed fee.
205+
// A larger value passes this check. This is not an exact amount check or a request
206+
// to claim that many millisatoshis. Validate the event's amount before claiming.
207+
// If not provided, skips this amount check.
205208
optional uint64 claimable_amount_msat = 2;
206209

207210
// The hex-encoded 32-byte payment preimage.
@@ -1018,7 +1021,15 @@ message DecodeOfferResponse {
10181021
bool is_expired = 12;
10191022
}
10201023

1021-
// Subscribe to a stream of server events.
1024+
// Subscribe to a best-effort stream of new server events.
1025+
//
1026+
// Events are not persisted for subscribers or replayed after reconnecting, and the server does not
1027+
// wait for client acknowledgement. Slow or disconnected subscribers may miss events. Reconcile
1028+
// recoverable state with the listing and detail APIs after reconnecting. Some event fields,
1029+
// including inputs required for payer proofs, cannot be recovered through these APIs.
1030+
//
1031+
// If a PaymentClaimable event is missed and the payment is not otherwise claimed or failed, LDK
1032+
// Node automatically fails the HTLC backward at its claim_deadline.
10221033
message SubscribeEventsRequest {}
10231034

10241035
service LightningNode {

ldk-server-grpc/src/proto/events.proto

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,9 @@ message PaymentFailed {
184184
// This event is only emitted for payments created via `Bolt11ReceiveForHash`.
185185
// Handle every event by its payment ID before `claim_deadline`.
186186
// The same invoice can produce more than one event. Fail unexpected duplicate or late payments.
187+
// Delivery through SubscribeEvents is best-effort and is not replayed. If the event is missed and
188+
// the payment is not otherwise claimed or failed, LDK Node automatically fails the HTLC backward at
189+
// claim_deadline.
187190
message PaymentClaimable {
188191
// The local identifier used to track the payment, in hex-encoded form.
189192
string payment_id = 1;
@@ -193,6 +196,9 @@ message PaymentClaimable {
193196
repeated types.CustomTlvRecord custom_records = 3;
194197
// The block height by which this payment must be claimed before it is failed back.
195198
optional uint32 claim_deadline = 4;
199+
// The amount in millisatoshis that is claimable. Validate this against the amount you expect
200+
// before calling Bolt11ClaimForId, and pass this value as its claimable_amount_msat.
201+
uint64 claimable_amount_msat = 5;
196202
}
197203

198204
// PaymentForwarded indicates a payment was forwarded through the node.

ldk-server/src/main.rs

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,7 @@ fn main() {
560560
&event_sender);
561561

562562
},
563-
Event::PaymentClaimable { payment_id, custom_records, claim_deadline, .. } => {
563+
Event::PaymentClaimable { payment_id, custom_records, claim_deadline, claimable_amount_msat, .. } => {
564564
send_payment_event(
565565
&payment_id,
566566
|payment| {
@@ -569,6 +569,7 @@ fn main() {
569569
payment,
570570
&custom_records,
571571
claim_deadline,
572+
claimable_amount_msat,
572573
payment_id.to_string(),
573574
),
574575
)
@@ -776,24 +777,17 @@ fn send_payment_event(
776777
if let Err(e) = event_sender.send(EventEnvelope { event: Some(event) }) {
777778
debug!("No event subscribers connected, skipping event: {e}");
778779
}
779-
780-
if let Err(e) = event_node.event_handled() {
781-
error!("Failed to mark event as handled: {e}");
782-
}
783780
},
784781
Ok(None) => {
785782
error!("Unable to find payment with payment ID: {payment_id}");
786-
if let Err(e) = event_node.event_handled() {
787-
error!("Failed to mark event as handled: {e}");
788-
}
789783
},
790784
Err(e) => {
791785
error!("Failed to retrieve payment with payment ID {payment_id}: {e}");
792-
if let Err(e) = event_node.event_handled() {
793-
error!("Failed to mark event as handled: {e}");
794-
}
795786
},
796787
}
788+
if let Err(e) = event_node.event_handled() {
789+
error!("Failed to mark event as handled: {e}");
790+
}
797791
}
798792

799793
fn send_channel_state_event(
@@ -1012,7 +1006,7 @@ fn load_or_generate_api_key(storage_dir: &Path) -> std::io::Result<String> {
10121006

10131007
fn build_payment_claimable_proto(
10141008
payment: Payment, custom_records: &[CustomTlvRecord], claim_deadline: Option<u32>,
1015-
payment_id: String,
1009+
claimable_amount_msat: u64, payment_id: String,
10161010
) -> events::PaymentClaimable {
10171011
let proto_custom_records: Vec<_> =
10181012
custom_records.iter().map(node_to_proto_custom_tlv).collect();
@@ -1021,6 +1015,7 @@ fn build_payment_claimable_proto(
10211015
payment: Some(payment),
10221016
custom_records: proto_custom_records,
10231017
claim_deadline,
1018+
claimable_amount_msat,
10241019
}
10251020
}
10261021

@@ -1143,14 +1138,24 @@ mod tests {
11431138
}
11441139

11451140
#[test]
1146-
fn payment_claimable_proto_contains_custom_records() {
1141+
fn payment_claimable_proto_preserves_event_fields() {
11471142
let payment = ldk_server_grpc::types::Payment::default();
11481143
let records = vec![
11491144
CustomTlvRecord { type_num: 65537, value: vec![1, 2, 3] },
11501145
CustomTlvRecord { type_num: 65538, value: Vec::new() },
11511146
];
1152-
let proto = build_payment_claimable_proto(payment, &records, None, "abc123".to_string());
1147+
let proto = build_payment_claimable_proto(
1148+
payment,
1149+
&records,
1150+
Some(800_000),
1151+
42_123,
1152+
"abc123".to_string(),
1153+
);
1154+
let encoded = proto.encode_to_vec();
1155+
let proto = events::PaymentClaimable::decode(encoded.as_slice()).unwrap();
11531156
assert_eq!(proto.payment_id, "abc123");
1157+
assert_eq!(proto.claim_deadline, Some(800_000));
1158+
assert_eq!(proto.claimable_amount_msat, 42_123);
11541159
assert_eq!(proto.custom_records.len(), 2);
11551160
assert_eq!(proto.custom_records[0].type_num, 65537);
11561161
assert_eq!(proto.custom_records[0].value.to_vec(), vec![1, 2, 3]);

ldk-server/src/util/metrics.rs

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
use std::sync::atomic::{AtomicI64, AtomicU64, Ordering};
2727

2828
use ldk_node::payment::PaymentStatus;
29-
use ldk_node::{Node, NodeError};
29+
use ldk_node::{ChannelDetails, Node, NodeError};
3030
use log::error;
3131

3232
#[derive(Default)]
@@ -123,20 +123,19 @@ impl Metrics {
123123
let all_channels = node.list_channels();
124124
self.total_channels_count.store(all_channels.len() as i64, Ordering::Relaxed);
125125

126-
let public_channels_count =
127-
all_channels.iter().filter(|channel_details| channel_details.is_announced).count()
128-
as i64;
129-
self.total_public_channels_count.store(public_channels_count, Ordering::Relaxed);
130-
131-
let private_channels_count =
132-
all_channels.iter().filter(|channel_details| !channel_details.is_announced).count()
133-
as i64;
134-
self.total_private_channels_count.store(private_channels_count, Ordering::Relaxed);
126+
self.update_channel_visibility_counts(&all_channels);
135127

136128
self.update_peer_count(node);
137129
self.update_all_balances(node);
138130
}
139131

132+
fn update_channel_visibility_counts(&self, channels: &[ChannelDetails]) {
133+
let public_count = channels.iter().filter(|channel| channel.is_announced).count();
134+
let private_count = channels.len() - public_count;
135+
self.total_public_channels_count.store(public_count as i64, Ordering::Relaxed);
136+
self.total_private_channels_count.store(private_count as i64, Ordering::Relaxed);
137+
}
138+
140139
pub fn update_all_balances(&self, node: &Node) {
141140
let all_balances = node.list_balances();
142141
self.total_onchain_balance_sats
@@ -160,15 +159,7 @@ impl Metrics {
160159
Err(e) => error!("Failed to update payment metrics: {e}"),
161160
}
162161

163-
let public_channels_count =
164-
all_channels.iter().filter(|channel_details| channel_details.is_announced).count()
165-
as i64;
166-
self.total_public_channels_count.store(public_channels_count, Ordering::Relaxed);
167-
168-
let private_channels_count =
169-
all_channels.iter().filter(|channel_details| !channel_details.is_announced).count()
170-
as i64;
171-
self.total_private_channels_count.store(private_channels_count, Ordering::Relaxed);
162+
self.update_channel_visibility_counts(&all_channels);
172163

173164
self.update_peer_count(node);
174165
self.update_all_balances(node);

0 commit comments

Comments
 (0)