From a2955e7b51780e2b5bab0963527ce545dc18cd10 Mon Sep 17 00:00:00 2001 From: shaavan Date: Mon, 31 Aug 2026 18:54:01 +0530 Subject: [PATCH 1/7] [plumb] Update rust-lightning to recurrence fork Update LDK Node's Cargo.toml to use recurrence-aware versions of rust-lightning and bitcoin-payment-instructions. This makes the recurrence APIs available for implementing recurrence support in subsequent commits. Co-authored-by: GLaDOS --- CHANGELOG.md | 3 ++ Cargo.toml | 30 +++++++++---------- src/data_store.rs | 6 ++-- src/event.rs | 17 ++++++++--- src/ffi/types.rs | 8 +++-- src/io/utils.rs | 4 +-- src/io/vss_store.rs | 4 +-- src/lib.rs | 6 ++-- .../asynchronous/static_invoice_store.rs | 4 +-- src/payment/bolt11.rs | 6 ++-- src/payment/pending_payment_store.rs | 6 ++-- src/payment/store.rs | 24 +++++++-------- src/payment/unified.rs | 5 ++++ src/peer_store.rs | 4 +-- src/types.rs | 4 +-- src/wallet/persist.rs | 4 +-- 16 files changed, 78 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b6c6e97b1..0e79477a9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Pending ## Compatibility Notes +- `Bolt11Invoice::recover_payee_pub_key` now returns `Option` in the public Rust and + FFI APIs. Callers must handle `None` when signature-based payee-key recovery fails; callers + needing the effective payee key should use the appropriate non-recovery accessor when available. - The language bindings now expose `Mnemonic` as an object instead of a string alias. Existing mnemonic phrases must be passed through its fallible constructor, which returns `NodeError::InvalidMnemonic` for invalid input; generated mnemonics can be converted back to a diff --git a/Cargo.toml b/Cargo.toml index 0110c027b0..fccea4a552 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,18 +86,18 @@ uniffi-default = [ #lightning-macros = { version = "0.2.0" } #lightning-dns-resolver = { version = "0.3.0" } -lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "9174965af9437196c527a9aa0df36bbcf050c8bb", features = ["std"] } -lightning-types = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "9174965af9437196c527a9aa0df36bbcf050c8bb" } -lightning-invoice = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "9174965af9437196c527a9aa0df36bbcf050c8bb", features = ["std"] } -lightning-net-tokio = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "9174965af9437196c527a9aa0df36bbcf050c8bb" } -lightning-persister = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "9174965af9437196c527a9aa0df36bbcf050c8bb", features = ["tokio"], optional = true } -lightning-background-processor = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "9174965af9437196c527a9aa0df36bbcf050c8bb" } -lightning-rapid-gossip-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "9174965af9437196c527a9aa0df36bbcf050c8bb" } -lightning-block-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "9174965af9437196c527a9aa0df36bbcf050c8bb", features = ["rest-client", "rpc-client", "tokio"], optional = true } -lightning-transaction-sync = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "9174965af9437196c527a9aa0df36bbcf050c8bb", optional = true } -lightning-liquidity = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "9174965af9437196c527a9aa0df36bbcf050c8bb", features = ["std"] } -lightning-macros = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "9174965af9437196c527a9aa0df36bbcf050c8bb" } -lightning-dns-resolver = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "9174965af9437196c527a9aa0df36bbcf050c8bb", optional = true } +lightning = { git = "https://git.rust-bitcoin.org/shaavan/rust-lightning", rev = "e09a607cd87f181b1f9a353ddcc4646b8e08b729", features = ["std"] } +lightning-types = { git = "https://git.rust-bitcoin.org/shaavan/rust-lightning", rev = "e09a607cd87f181b1f9a353ddcc4646b8e08b729" } +lightning-invoice = { git = "https://git.rust-bitcoin.org/shaavan/rust-lightning", rev = "e09a607cd87f181b1f9a353ddcc4646b8e08b729", features = ["std"] } +lightning-net-tokio = { git = "https://git.rust-bitcoin.org/shaavan/rust-lightning", rev = "e09a607cd87f181b1f9a353ddcc4646b8e08b729" } +lightning-persister = { git = "https://git.rust-bitcoin.org/shaavan/rust-lightning", rev = "e09a607cd87f181b1f9a353ddcc4646b8e08b729", features = ["tokio"], optional = true } +lightning-background-processor = { git = "https://git.rust-bitcoin.org/shaavan/rust-lightning", rev = "e09a607cd87f181b1f9a353ddcc4646b8e08b729" } +lightning-rapid-gossip-sync = { git = "https://git.rust-bitcoin.org/shaavan/rust-lightning", rev = "e09a607cd87f181b1f9a353ddcc4646b8e08b729" } +lightning-block-sync = { git = "https://git.rust-bitcoin.org/shaavan/rust-lightning", rev = "e09a607cd87f181b1f9a353ddcc4646b8e08b729", features = ["rest-client", "rpc-client", "tokio"], optional = true } +lightning-transaction-sync = { git = "https://git.rust-bitcoin.org/shaavan/rust-lightning", rev = "e09a607cd87f181b1f9a353ddcc4646b8e08b729", optional = true } +lightning-liquidity = { git = "https://git.rust-bitcoin.org/shaavan/rust-lightning", rev = "e09a607cd87f181b1f9a353ddcc4646b8e08b729", features = ["std"] } +lightning-macros = { git = "https://git.rust-bitcoin.org/shaavan/rust-lightning", rev = "e09a607cd87f181b1f9a353ddcc4646b8e08b729" } +lightning-dns-resolver = { git = "https://git.rust-bitcoin.org/shaavan/rust-lightning", rev = "e09a607cd87f181b1f9a353ddcc4646b8e08b729", optional = true } bdk_chain = { version = "0.23.3", default-features = false, features = ["std"] } bdk_esplora = { version = "0.22.2", default-features = false, features = ["async-https-rustls", "tokio"], optional = true } @@ -132,14 +132,14 @@ postgres-native-tls = { version = "0.5", default-features = false, features = [" vss-client = { package = "vss-client-ng", version = "0.6", optional = true } prost = { version = "0.11.6", default-features = false, optional = true} #bitcoin-payment-instructions = { version = "0.6" } -bitcoin-payment-instructions = { git = "https://github.com/tnull/bitcoin-payment-instructions", rev = "1d6ffaa8962391ddc84aeef98bd7439e55ccae9d", optional = true } +bitcoin-payment-instructions = { git = "https://git.rust-bitcoin.org/shaavan/bitcoin-payment-instructions.git", rev = "1d50eb439bf019a3822635d0a7b0d8f548685aa0", optional = true } [target.'cfg(windows)'.dependencies] winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] -lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "9174965af9437196c527a9aa0df36bbcf050c8bb", features = ["std", "_test_utils"] } -lightning-persister = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "9174965af9437196c527a9aa0df36bbcf050c8bb", features = ["tokio"] } +lightning = { git = "https://git.rust-bitcoin.org/shaavan/rust-lightning", rev = "e09a607cd87f181b1f9a353ddcc4646b8e08b729", features = ["std", "_test_utils"] } +lightning-persister = { git = "https://git.rust-bitcoin.org/shaavan/rust-lightning", rev = "e09a607cd87f181b1f9a353ddcc4646b8e08b729", features = ["tokio"] } rand = { version = "0.9.2", default-features = false, features = ["std", "thread_rng", "os_rng"] } proptest = "1.0.0" regex = "1.5.6" diff --git a/src/data_store.rs b/src/data_store.rs index a9fe0d0f59..7fd51b3908 100644 --- a/src/data_store.rs +++ b/src/data_store.rs @@ -752,7 +752,7 @@ mod tests { use lightning::util::persist::{PageToken, PaginatedKVStore, PaginatedListResponse}; use lightning::util::test_utils::TestLogger; - use lightning::{impl_writeable_tlv_based, io}; + use lightning::{impl_ser_tlv_based, io}; use tokio::sync::Notify; use super::*; @@ -802,7 +802,7 @@ mod tests { hex_utils::to_vec(s)?.try_into().ok().map(|id| Self { id }) } } - impl_writeable_tlv_based!(TestObjectId, { (0, id, required) }); + impl_ser_tlv_based!(TestObjectId, { (0, id, required) }); struct TestObjectUpdate { id: TestObjectId, @@ -858,7 +858,7 @@ mod tests { } } - impl_writeable_tlv_based!(TestObject, { + impl_ser_tlv_based!(TestObject, { (0, id, required), (2, data, required), (4, extra, option), diff --git a/src/event.rs b/src/event.rs index 0a35697552..d89cafecdc 100644 --- a/src/event.rs +++ b/src/event.rs @@ -24,13 +24,14 @@ use lightning::events::{ }; use lightning::ln::channelmanager::{PaymentId, TrustedChannelFeatures}; use lightning::ln::types::ChannelId; +use lightning::offers::offer::OfferId; use lightning::routing::gossip::NodeId; use lightning::sign::EntropySource; use lightning::util::config::{ChannelConfigOverrides, ChannelConfigUpdate}; use lightning::util::errors::APIError; use lightning::util::persist::KVStore; use lightning::util::ser::{Readable, ReadableArgs, Writeable, Writer}; -use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; +use lightning::{impl_ser_tlv_based, impl_ser_tlv_based_enum}; use lightning_liquidity::lsps2::utils::compute_opening_fee; use lightning_types::payment::{PaymentHash, PaymentPreimage}; @@ -84,7 +85,7 @@ pub struct HTLCLocator { pub node_id: Option, } -impl_writeable_tlv_based!(HTLCLocator, { +impl_ser_tlv_based!(HTLCLocator, { (1, channel_id, required), (3, user_channel_id, option), (5, node_id, option), @@ -308,7 +309,7 @@ pub enum Event { }, } -impl_writeable_tlv_based_enum!(Event, +impl_ser_tlv_based_enum!(Event, (0, PaymentSuccessful) => { (0, payment_hash, required), (1, fee_paid_msat, option), @@ -824,6 +825,14 @@ where .lsps2_service() .lsps2_funding_tx_broadcast_safe(user_channel_id, counterparty_node_id); }, + LdkEvent::RecurringOfferCancelled { .. } => { + // TODO: Mark the corresponding recurrence as cancelled once the + // recurrence data store is introduced. + debug_assert!( + false, + "recurrence cancellation handling is not implemented" + ); + }, LdkEvent::PaymentClaimable { payment_id, payment_hash, @@ -2342,7 +2351,7 @@ mod tests { }, } - impl_writeable_tlv_based_enum!(LegacyEvent, + impl_ser_tlv_based_enum!(LegacyEvent, (5, ChannelClosed) => { (0, channel_id, required), (1, counterparty_node_id, option), diff --git a/src/ffi/types.rs b/src/ffi/types.rs index d213f9d51e..02ad0cb4c8 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -1509,8 +1509,12 @@ impl Bolt11Invoice { .collect() } - /// Recover the payee's public key (only to be used if none was included in the invoice) - pub fn recover_payee_pub_key(&self) -> PublicKey { + /// Recover the payee's public key (only to be used if none was included in the invoice). + /// + /// Signature recovery can fail if the invoice does not contain enough information to recover + /// the key; `None` indicates that recovery failed. Callers needing the invoice's effective + /// payee key should use the appropriate non-recovery accessor when available. + pub fn recover_payee_pub_key(&self) -> Option { self.inner.recover_payee_pub_key() } } diff --git a/src/io/utils.rs b/src/io/utils.rs index 30fc0c62d2..8598a0302a 100644 --- a/src/io/utils.rs +++ b/src/io/utils.rs @@ -779,7 +779,7 @@ mod read_objects_tests { use std::num::NonZeroUsize; use std::sync::Arc; - use lightning::impl_writeable_tlv_based; + use lightning::impl_ser_tlv_based; use lightning::util::persist::{KVStore, PageToken, PaginatedKVStore, PaginatedListResponse}; use lightning::util::ser::Writeable; use lightning::util::test_utils::TestLogger; @@ -796,7 +796,7 @@ mod read_objects_tests { struct TestObject { id: u32, } - impl_writeable_tlv_based!(TestObject, { (0, id, required) }); + impl_ser_tlv_based!(TestObject, { (0, id, required) }); /// Writes `num_objects` objects with ascending ids, so that the highest id is the most /// recently created one. diff --git a/src/io/vss_store.rs b/src/io/vss_store.rs index 6a44be02d9..78466bdfa3 100644 --- a/src/io/vss_store.rs +++ b/src/io/vss_store.rs @@ -21,7 +21,7 @@ use bitcoin::bip32::{ChildNumber, Xpriv}; use bitcoin::hashes::{sha256, Hash, HashEngine, Hmac, HmacEngine}; use bitcoin::key::Secp256k1; use bitcoin::Network; -use lightning::impl_writeable_tlv_based_enum; +use lightning::impl_ser_tlv_based_enum; use lightning::io::{self, Error, ErrorKind}; use lightning::sign::{EntropySource as LdkEntropySource, RandomBytes}; use lightning::util::persist::{ @@ -67,7 +67,7 @@ enum VssSchemaVersion { V1, } -impl_writeable_tlv_based_enum!(VssSchemaVersion, +impl_ser_tlv_based_enum!(VssSchemaVersion, (0, V0) => {}, (1, V1) => {}, ); diff --git a/src/lib.rs b/src/lib.rs index 821304a532..9e32009332 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -152,7 +152,7 @@ use graph::NetworkGraph; use io::utils::update_and_persist_node_metrics; pub use lightning; use lightning::chain::BlockLocator; -use lightning::impl_writeable_tlv_based; +use lightning::impl_ser_tlv_based; use lightning::ln::chan_utils::FUNDING_TRANSACTION_WITNESS_WEIGHT; use lightning::ln::channel_state::ChannelDetails as LdkChannelDetails; pub use lightning::ln::channel_state::ChannelShutdownState; @@ -2506,7 +2506,7 @@ impl PersistedNodeMetrics { } } -impl_writeable_tlv_based!(NodeMetrics, { +impl_ser_tlv_based!(NodeMetrics, { (0, latest_lightning_wallet_sync_timestamp, option), (1, latest_pathfinding_scores_sync_timestamp, option), (2, latest_onchain_wallet_sync_timestamp, option), @@ -2590,7 +2590,7 @@ mod tests { latest_pathfinding_scores_sync_timestamp: Option, latest_node_announcement_broadcast_timestamp: Option, } - impl_writeable_tlv_based!(OldNodeMetrics, { + impl_ser_tlv_based!(OldNodeMetrics, { (0, latest_lightning_wallet_sync_timestamp, option), (1, latest_pathfinding_scores_sync_timestamp, option), (2, latest_onchain_wallet_sync_timestamp, option), diff --git a/src/payment/asynchronous/static_invoice_store.rs b/src/payment/asynchronous/static_invoice_store.rs index f1e2378c23..85d9479234 100644 --- a/src/payment/asynchronous/static_invoice_store.rs +++ b/src/payment/asynchronous/static_invoice_store.rs @@ -13,7 +13,7 @@ use std::time::Duration; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; use lightning::blinded_path::message::BlindedMessagePath; -use lightning::impl_writeable_tlv_based; +use lightning::impl_ser_tlv_based; use lightning::offers::static_invoice::StaticInvoice; use lightning::util::persist::KVStore; use lightning::util::ser::{Readable, Writeable}; @@ -28,7 +28,7 @@ struct PersistedStaticInvoice { request_path: BlindedMessagePath, } -impl_writeable_tlv_based!(PersistedStaticInvoice, { +impl_ser_tlv_based!(PersistedStaticInvoice, { (0, invoice, required), (2, request_path, required) }); diff --git a/src/payment/bolt11.rs b/src/payment/bolt11.rs index 5f0a70c2c6..c167a27daf 100644 --- a/src/payment/bolt11.rs +++ b/src/payment/bolt11.rs @@ -13,7 +13,7 @@ use std::sync::{Arc, RwLock}; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; -use lightning::impl_writeable_tlv_based; +use lightning::impl_ser_tlv_based; use lightning::ln::channelmanager::{ Bolt11InvoiceParameters, OptionalBolt11PaymentParams, PaymentId, }; @@ -55,7 +55,7 @@ pub(crate) struct PaymentMetadata { pub(crate) lsps2_parameters: Option, } -impl_writeable_tlv_based!(PaymentMetadata, { +impl_ser_tlv_based!(PaymentMetadata, { (0, lsps2_parameters, option), }); @@ -245,7 +245,7 @@ impl Bolt11Payment { let payee_pubkey = invoice.recover_payee_pub_key(); log_info!( self.logger, - "Initiated sending {} msat to {}", + "Initiated sending {} msat to {:?}", payment_amount_msat, payee_pubkey ); diff --git a/src/payment/pending_payment_store.rs b/src/payment/pending_payment_store.rs index 30a1135374..a0fad8984c 100644 --- a/src/payment/pending_payment_store.rs +++ b/src/payment/pending_payment_store.rs @@ -6,7 +6,7 @@ // accordance with one or both of these licenses. use bitcoin::Txid; -use lightning::impl_writeable_tlv_based; +use lightning::impl_ser_tlv_based; use lightning::ln::channelmanager::PaymentId; use crate::data_store::{StorableObject, StorableObjectUpdate}; @@ -30,7 +30,7 @@ pub(crate) struct FundingTxCandidate { pub fee_paid_msat: Option, } -impl_writeable_tlv_based!(FundingTxCandidate, { +impl_ser_tlv_based!(FundingTxCandidate, { (0, txid, required), (2, amount_msat, option), (4, fee_paid_msat, option), @@ -62,7 +62,7 @@ impl PendingPaymentDetails { } } -impl_writeable_tlv_based!(PendingPaymentDetails, { +impl_ser_tlv_based!(PendingPaymentDetails, { (0, details, required), (2, conflicting_txids, optional_vec), (4, candidates, optional_vec), diff --git a/src/payment/store.rs b/src/payment/store.rs index 3163ed15b7..336b968a2c 100644 --- a/src/payment/store.rs +++ b/src/payment/store.rs @@ -16,8 +16,8 @@ use lightning::ln::types::ChannelId; use lightning::offers::offer::OfferId; use lightning::util::ser::{Readable, Writeable}; use lightning::{ - _init_and_read_len_prefixed_tlv_fields, impl_writeable_tlv_based, - impl_writeable_tlv_based_enum, write_tlv_fields, + _init_and_read_len_prefixed_tlv_fields, impl_ser_tlv_based, impl_ser_tlv_based_enum, + write_tlv_fields, }; use lightning_types::payment::{PaymentHash, PaymentPreimage, PaymentSecret}; use lightning_types::string::UntrustedString; @@ -374,7 +374,7 @@ pub enum PaymentDirection { Outbound, } -impl_writeable_tlv_based_enum!(PaymentDirection, +impl_ser_tlv_based_enum!(PaymentDirection, (0, Inbound) => {}, (1, Outbound) => {} ); @@ -391,7 +391,7 @@ pub enum PaymentStatus { Failed, } -impl_writeable_tlv_based_enum!(PaymentStatus, +impl_ser_tlv_based_enum!(PaymentStatus, (0, Pending) => {}, (2, Succeeded) => {}, (4, Failed) => {} @@ -407,7 +407,7 @@ pub struct Channel { pub channel_id: ChannelId, } -impl_writeable_tlv_based!(Channel, { +impl_ser_tlv_based!(Channel, { (0, counterparty_node_id, required), (2, channel_id, required), }); @@ -467,7 +467,7 @@ pub enum TransactionType { }, } -impl_writeable_tlv_based_enum!(TransactionType, +impl_ser_tlv_based_enum!(TransactionType, (0, Funding) => { (0, channels, optional_vec), }, @@ -649,7 +649,7 @@ pub enum PaymentKind { }, } -impl_writeable_tlv_based_enum!(PaymentKind, +impl_ser_tlv_based_enum!(PaymentKind, (0, Onchain) => { (0, txid, required), (1, tx_type, option), @@ -709,7 +709,7 @@ pub enum ConfirmationStatus { Unconfirmed, } -impl_writeable_tlv_based_enum!(ConfirmationStatus, +impl_ser_tlv_based_enum!(ConfirmationStatus, (0, Confirmed) => { (0, block_hash, required), (2, height, required), @@ -734,7 +734,7 @@ pub struct LSPS2Parameters { pub max_proportional_opening_fee_ppm_msat: Option, } -impl_writeable_tlv_based!(LSPS2Parameters, { +impl_ser_tlv_based!(LSPS2Parameters, { (0, max_total_opening_fee_msat, option), (2, max_proportional_opening_fee_ppm_msat, option), }); @@ -866,7 +866,7 @@ mod tests { pub status: PaymentStatus, } - impl_writeable_tlv_based!(OldPaymentDetails, { + impl_ser_tlv_based!(OldPaymentDetails, { (0, hash, required), (2, preimage, required), (4, secret, required), @@ -965,7 +965,7 @@ mod tests { status: ConfirmationStatus, } - impl_writeable_tlv_based!(OldOnchainKind, { + impl_ser_tlv_based!(OldOnchainKind, { (0, txid, required), (2, status, required), }); @@ -1487,7 +1487,7 @@ mod tests { lsp_fee_limits: LSPS2Parameters, } - impl_writeable_tlv_based!(LegacyBolt11JitKind, { + impl_ser_tlv_based!(LegacyBolt11JitKind, { (0, hash, required), (1, counterparty_skimmed_fee_msat, option), (2, preimage, option), diff --git a/src/payment/unified.rs b/src/payment/unified.rs index e10d57ba00..f43bca50ab 100644 --- a/src/payment/unified.rs +++ b/src/payment/unified.rs @@ -260,6 +260,8 @@ impl UnifiedPayment { PaymentMethod::LightningBolt12(_) => 0, PaymentMethod::LightningBolt11(_) => 1, PaymentMethod::OnChain(_) => 2, + PaymentMethod::Bark(_) => 3, + PaymentMethod::Cashu(_) => 4, }); for method in sorted_payment_methods { @@ -338,6 +340,9 @@ impl UnifiedPayment { .await?; return Ok(UnifiedPaymentResult::Onchain { txid }); }, + // Bark and Cashu are valid BIP 21 payment methods, but LDK Node does + // not support sending payments with them yet. Try the next fallback method. + PaymentMethod::Bark(_) | PaymentMethod::Cashu(_) => {}, } } diff --git a/src/peer_store.rs b/src/peer_store.rs index 8345bf7111..2e7fdd240e 100644 --- a/src/peer_store.rs +++ b/src/peer_store.rs @@ -10,7 +10,7 @@ use std::ops::Deref; use std::sync::{Arc, RwLock}; use bitcoin::secp256k1::PublicKey; -use lightning::impl_writeable_tlv_based; +use lightning::impl_ser_tlv_based; use lightning::util::persist::KVStore; use lightning::util::ser::{Readable, ReadableArgs, Writeable, Writer}; @@ -163,7 +163,7 @@ pub(crate) struct PeerInfo { pub address: SocketAddress, } -impl_writeable_tlv_based!(PeerInfo, { +impl_ser_tlv_based!(PeerInfo, { (0, node_id, required), (2, address, required), }); diff --git a/src/types.rs b/src/types.rs index 1a61daa109..7297db3a5c 100644 --- a/src/types.rs +++ b/src/types.rs @@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex}; use bitcoin::secp256k1::PublicKey; use bitcoin::{OutPoint, ScriptBuf}; use lightning::chain::chainmonitor; -use lightning::impl_writeable_tlv_based; +use lightning::impl_ser_tlv_based; use lightning::ln::channel_state::{ ChannelDetails as LdkChannelDetails, ChannelShutdownState, CounterpartyForwardingInfo, }; @@ -703,7 +703,7 @@ pub struct CustomTlvRecord { pub value: Vec, } -impl_writeable_tlv_based!(CustomTlvRecord, { +impl_ser_tlv_based!(CustomTlvRecord, { (0, type_num, required), (2, value, required), }); diff --git a/src/wallet/persist.rs b/src/wallet/persist.rs index 6384d0fcee..296a58683d 100644 --- a/src/wallet/persist.rs +++ b/src/wallet/persist.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use bdk_chain::Merge; use bdk_wallet::{AsyncWalletPersister, ChangeSet}; -use lightning::impl_writeable_tlv_based; +use lightning::impl_ser_tlv_based; use lightning::util::persist::KVStore; use lightning::util::ser::{Readable, Writeable}; @@ -39,7 +39,7 @@ struct AddressPoolRecord { indices: Vec, } -impl_writeable_tlv_based!(AddressPoolRecord, { +impl_ser_tlv_based!(AddressPoolRecord, { (0, indices, required_vec), }); From c95c2950e153523c6d321698201fe8a5e174ac14 Mon Sep 17 00:00:00 2001 From: shaavan Date: Sun, 6 Sep 2026 21:03:34 +0530 Subject: [PATCH 2/7] [feat] Introduce RecurrenceStore Recurring offers outlive individual payment attempts and node processes. To resume a recurrence safely after restart, the node must retain its original offer terms, lifecycle state, payment progress, and payee-provided opaque state. Add RecurrenceDetails and RecurrenceStatus with TLV serialization and RecurrenceId-based persistence. Load recurrence records during node construction under a dedicated "recurrences" namespace, retaining all entries for the lifetime of each recurrence. Thread RecurrenceStore through Node, EventHandler, and Bolt12Payment to establish the persistence foundation for recurrence scheduling and payment handling. Recurring payment execution remains disabled in this commit. Co-Authored-By: GLaDOS --- bindings/ldk_node.udl | 4 + src/builder.rs | 75 +++++++--- src/error.rs | 3 + src/event.rs | 19 ++- src/ffi/types.rs | 17 +++ src/io/mod.rs | 5 + src/lib.rs | 30 +++- src/payment/bolt12.rs | 7 +- src/payment/mod.rs | 5 + src/payment/outbound_recurrence.rs | 215 +++++++++++++++++++++++++++++ src/types.rs | 4 + 11 files changed, 347 insertions(+), 37 deletions(-) create mode 100644 src/payment/outbound_recurrence.rs diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 4c4c1a438a..2cf1b0f976 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -176,6 +176,7 @@ enum NodeError { "InvalidMnemonic", "InvalidOfferId", "InvalidNodeId", + "InvalidRecurrenceId", "InvalidPaymentId", "InvalidPaymentHash", "InvalidPaymentPreimage", @@ -355,6 +356,9 @@ typedef string Address; [Custom] typedef string OfferId; +[Custom] +typedef string RecurrenceId; + [Custom] typedef string PaymentId; diff --git a/src/builder.rs b/src/builder.rs index ab641cf86b..3eb647035f 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -80,7 +80,9 @@ use crate::io::utils::{ #[cfg(feature = "storage-vss")] use crate::io::vss_store::VssStoreBuilder; use crate::io::{ - self, PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + self, OUTBOUND_RECURRENCE_INFO_PERSISTENCE_PRIMARY_NAMESPACE, + OUTBOUND_RECURRENCE_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, }; @@ -100,8 +102,8 @@ use crate::runtime::{Runtime, RuntimeSpawner}; use crate::tx_broadcaster::TransactionBroadcaster; use crate::types::{ AsyncPersister, ChainMonitor, ChannelManager, DynStore, DynStoreRef, DynStoreWrapper, - GossipSync, Graph, KeysManager, MessageRouter, OnionMessenger, PaymentStore, PeerManager, - PendingPaymentStore, + GossipSync, Graph, KeysManager, MessageRouter, OnionMessenger, OutboundRecurrenceStore, + PaymentStore, PeerManager, PendingPaymentStore, }; use crate::wallet::persist::{read_address_pool, KVStoreWalletPersister}; use crate::wallet::Wallet; @@ -1524,26 +1526,37 @@ fn build_with_store_internal( let kv_store_ref = Arc::clone(&kv_store); let logger_ref = Arc::clone(&logger); - let (payment_store_res, node_metris_res, pending_payment_store_res, address_pool_res) = runtime - .block_on(async move { - tokio::join!( - read_n_objects( - &*kv_store_ref, - PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, - PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, - PAYMENT_CACHE_WARMUP_COUNT, - Arc::clone(&logger_ref), - ), - read_node_metrics(&*kv_store_ref, Arc::clone(&logger_ref)), - read_all_objects( - &*kv_store_ref, - PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, - PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, - Arc::clone(&logger_ref), - ), - read_address_pool(&*kv_store_ref, &*logger_ref) - ) - }); + let ( + payment_store_res, + node_metris_res, + pending_payment_store_res, + outbound_recurrence_res, + address_pool_res, + ) = runtime.block_on(async move { + tokio::join!( + read_n_objects( + &*kv_store_ref, + PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, + PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + PAYMENT_CACHE_WARMUP_COUNT, + Arc::clone(&logger_ref), + ), + read_node_metrics(&*kv_store_ref, Arc::clone(&logger_ref)), + read_all_objects( + &*kv_store_ref, + PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE, + PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + Arc::clone(&logger_ref), + ), + read_all_objects( + &*kv_store_ref, + OUTBOUND_RECURRENCE_INFO_PERSISTENCE_PRIMARY_NAMESPACE, + OUTBOUND_RECURRENCE_INFO_PERSISTENCE_SECONDARY_NAMESPACE, + Arc::clone(&logger_ref), + ), + read_address_pool(&*kv_store_ref, &*logger_ref) + ) + }); // Initialize the status fields. let node_metrics = match node_metris_res { @@ -1865,6 +1878,21 @@ fn build_with_store_internal( }, }; + let outbound_recurrence_store = match outbound_recurrence_res { + Ok(outbound_recurrences) => Arc::new(OutboundRecurrenceStore::new( + outbound_recurrences, + KeepAllEntries, + OUTBOUND_RECURRENCE_INFO_PERSISTENCE_PRIMARY_NAMESPACE.to_string(), + OUTBOUND_RECURRENCE_INFO_PERSISTENCE_SECONDARY_NAMESPACE.to_string(), + Arc::clone(&kv_store), + Arc::clone(&logger), + )), + Err(e) => { + log_error!(logger, "Failed to read outbound recurrence data from store: {}", e); + return Err(BuildError::ReadFailed); + }, + }; + let wallet = Arc::new(Wallet::new( bdk_wallet, wallet_persister, @@ -2484,6 +2512,7 @@ fn build_with_store_internal( scorer, peer_store, payment_store, + outbound_recurrence_store, lnurl_auth, is_running, node_metrics, diff --git a/src/error.rs b/src/error.rs index 485f944c25..876e2d57ec 100644 --- a/src/error.rs +++ b/src/error.rs @@ -87,6 +87,8 @@ pub enum Error { InvalidOfferId, /// The given node id is invalid. InvalidNodeId, + /// The given recurrence id is invalid. + InvalidRecurrenceId, /// The given payment id is invalid. InvalidPaymentId, /// The given payment hash is invalid. @@ -193,6 +195,7 @@ impl fmt::Display for Error { Self::InvalidMnemonic => write!(f, "The given BIP 39 mnemonic is invalid."), Self::InvalidOfferId => write!(f, "The given offer id is invalid."), Self::InvalidNodeId => write!(f, "The given node id is invalid."), + Self::InvalidRecurrenceId => write!(f, "The given recurrence id is invalid."), Self::InvalidPaymentId => write!(f, "The given payment id is invalid."), Self::InvalidPaymentHash => write!(f, "The given payment hash is invalid."), Self::InvalidPaymentPreimage => write!(f, "The given payment preimage is invalid."), diff --git a/src/event.rs b/src/event.rs index d89cafecdc..75fe5438ef 100644 --- a/src/event.rs +++ b/src/event.rs @@ -24,7 +24,6 @@ use lightning::events::{ }; use lightning::ln::channelmanager::{PaymentId, TrustedChannelFeatures}; use lightning::ln::types::ChannelId; -use lightning::offers::offer::OfferId; use lightning::routing::gossip::NodeId; use lightning::sign::EntropySource; use lightning::util::config::{ChannelConfigOverrides, ChannelConfigUpdate}; @@ -56,7 +55,8 @@ use crate::payment::PaymentMetadata; use crate::probing::Prober; use crate::runtime::Runtime; use crate::types::{ - CustomTlvRecord, DynStore, KeysManager, OnionMessenger, PaymentStore, Sweeper, Wallet, + CustomTlvRecord, DynStore, KeysManager, OnionMessenger, OutboundRecurrenceStore, PaymentStore, + Sweeper, Wallet, }; use crate::{ hex_utils, BumpTransactionEventHandler, ChannelManager, Error, Graph, PeerInfo, PeerStore, @@ -552,6 +552,7 @@ where network_graph: Arc, liquidity_source: Arc>>, payment_store: Arc, + outbound_recurrence_store: Arc, peer_store: Arc>, keys_manager: Arc, static_invoice_store: Option, @@ -573,10 +574,10 @@ where channel_manager: Arc, connection_manager: Arc>, output_sweeper: Arc, network_graph: Arc, liquidity_source: Arc>>, payment_store: Arc, - peer_store: Arc>, keys_manager: Arc, - static_invoice_store: Option, onion_messenger: Arc, - om_mailbox: Option>, prober: Option>, - runtime: Arc, logger: L, config: Arc, + outbound_recurrence_store: Arc, peer_store: Arc>, + keys_manager: Arc, static_invoice_store: Option, + onion_messenger: Arc, om_mailbox: Option>, + prober: Option>, runtime: Arc, logger: L, config: Arc, ) -> Self { Self { event_queue, @@ -588,6 +589,7 @@ where network_graph, liquidity_source, payment_store, + outbound_recurrence_store, peer_store, keys_manager, static_invoice_store, @@ -828,10 +830,7 @@ where LdkEvent::RecurringOfferCancelled { .. } => { // TODO: Mark the corresponding recurrence as cancelled once the // recurrence data store is introduced. - debug_assert!( - false, - "recurrence cancellation handling is not implemented" - ); + debug_assert!(false, "recurrence cancellation handling is not implemented"); }, LdkEvent::PaymentClaimable { payment_id, diff --git a/src/ffi/types.rs b/src/ffi/types.rs index 02ad0cb4c8..bdc96329bb 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -29,6 +29,7 @@ use lightning::ln::channelmanager::PaymentId; use lightning::ln::msgs::DecodeError; pub use lightning::ln::types::ChannelId; use lightning::offers::invoice::Bolt12Invoice as LdkBolt12Invoice; +use lightning::offers::invoice_request::RecurrenceId; pub use lightning::offers::offer::OfferId; use lightning::offers::offer::{Amount as LdkAmount, Offer as LdkOffer}; use lightning::offers::payer_proof::{ @@ -1044,6 +1045,22 @@ uniffi::custom_type!(OfferId, String, { }, }); +uniffi::custom_type!(RecurrenceId, String, { + remote, + try_lift: |val| { + if let Some(bytes_vec) = hex_utils::to_vec(&val) { + let bytes_res = bytes_vec.try_into(); + if let Ok(bytes) = bytes_res { + return Ok(RecurrenceId(bytes)); + } + } + Err(Error::InvalidRecurrenceId.into()) + }, + lower: |obj| { + hex_utils::to_string(&obj.0) + }, +}); + uniffi::custom_type!(PaymentId, String, { remote, try_lift: |val| { diff --git a/src/io/mod.rs b/src/io/mod.rs index c11475c431..aa988e026b 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -37,6 +37,11 @@ pub(crate) const PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "pending_payments"; pub(crate) const PENDING_PAYMENT_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; +/// Outbound recurrence records are persisted under this prefix. +pub(crate) const OUTBOUND_RECURRENCE_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = + "outbound_recurrences"; +pub(crate) const OUTBOUND_RECURRENCE_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; + /// The node metrics will be persisted under this key. pub(crate) const NODE_METRICS_PRIMARY_NAMESPACE: &str = ""; pub(crate) const NODE_METRICS_SECONDARY_NAMESPACE: &str = ""; diff --git a/src/lib.rs b/src/lib.rs index 9e32009332..52794e2e57 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -176,8 +176,8 @@ use logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use payment::asynchronous::om_mailbox::OnionMessageMailbox; use payment::asynchronous::static_invoice_store::StaticInvoiceStore; use payment::{ - Bolt11Payment, Bolt12Payment, OnchainPayment, PaymentDetails, PaymentDetailsPage, - SpontaneousPayment, + Bolt11Payment, Bolt12Payment, OnchainPayment, OutboundRecurrenceDetails, PaymentDetails, + PaymentDetailsPage, RecurrenceId, SpontaneousPayment, }; #[cfg(feature = "unified-payments")] use payment::{HRNResolver, UnifiedPayment}; @@ -201,6 +201,7 @@ use crate::config::{LIQUIDITY_DISCOVERY_RETRY_INITIAL_DELAY, LIQUIDITY_DISCOVERY use crate::ffi::{maybe_deref, maybe_wrap}; use crate::liquidity::Liquidity; use crate::scoring::setup_background_pathfinding_scores_sync; +use crate::types::OutboundRecurrenceStore; use crate::wallet::FundingAmount; #[cfg(not(feature = "uniffi"))] @@ -272,6 +273,7 @@ pub struct Node { peer_store: Arc>>, payment_store: Arc, lnurl_auth: Arc, + outbound_recurrence_store: Arc, is_running: Arc>, node_metrics: Arc, om_mailbox: Option>, @@ -678,6 +680,7 @@ impl Node { Arc::clone(&self.network_graph), Arc::clone(&self.liquidity_source), Arc::clone(&self.payment_store), + Arc::clone(&self.outbound_recurrence_store), Arc::clone(&self.peer_store), Arc::clone(&self.keys_manager), static_invoice_store, @@ -1075,6 +1078,7 @@ impl Node { Arc::clone(&self.channel_manager), Arc::clone(&self.keys_manager), Arc::clone(&self.payment_store), + Arc::clone(&self.outbound_recurrence_store), Arc::clone(&self.config), Arc::clone(&self.is_running), Arc::clone(&self.logger), @@ -1092,6 +1096,7 @@ impl Node { Arc::clone(&self.channel_manager), Arc::clone(&self.keys_manager), Arc::clone(&self.payment_store), + Arc::clone(&self.outbound_recurrence_store), Arc::clone(&self.config), Arc::clone(&self.is_running), Arc::clone(&self.logger), @@ -2158,6 +2163,27 @@ impl Node { self.runtime.block_on(self.payment_store.get(payment_id)) } + /// Retrieves an owned snapshot of the outbound recurrence with the given id. + /// + /// Returns `Ok(None)` for an unknown id, or an error if the record cannot be read. Available + /// before the node is started, including after records have been loaded during construction. + /// Changing the returned snapshot does not change the stored recurrence. + pub fn outbound_recurrence( + &self, recurrence_id: &RecurrenceId, + ) -> Result, Error> { + self.runtime.block_on(self.outbound_recurrence_store.get(recurrence_id)) + } + + /// Lists owned snapshots of all known outbound recurrences, in unspecified order. + /// + /// Includes terminal recurrences and failed attempts so callers can recover persisted records + /// after restart or an initiation error, even if the error left them without a recurrence id. + /// Records that were not successfully persisted cannot be recovered this way. Available before + /// the node is started; changing these snapshots does not update stored state. + pub fn list_outbound_recurrences(&self) -> Vec { + self.runtime.block_on(self.outbound_recurrence_store.list_filter(|_| true)) + } + /// Remove the payment with the given id from the store. pub fn remove_payment(&self, payment_id: &PaymentId) -> Result<(), Error> { self.runtime.block_on(self.payment_store.remove(&payment_id)) diff --git a/src/payment/bolt12.rs b/src/payment/bolt12.rs index 0e994d67ac..213c5998df 100644 --- a/src/payment/bolt12.rs +++ b/src/payment/bolt12.rs @@ -34,7 +34,7 @@ use crate::ffi::{maybe_deref, maybe_wrap}; use crate::logger::{log_error, log_info, LdkLogger, Logger}; use crate::payment::store::{PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus}; use crate::runtime::Runtime; -use crate::types::{ChannelManager, KeysManager, PaymentStore}; +use crate::types::{ChannelManager, KeysManager, OutboundRecurrenceStore, PaymentStore}; #[cfg(not(feature = "uniffi"))] type Bolt12Invoice = lightning::offers::invoice::Bolt12Invoice; @@ -98,6 +98,7 @@ pub struct Bolt12Payment { channel_manager: Arc, keys_manager: Arc, payment_store: Arc, + outbound_recurrence_store: Arc, config: Arc, is_running: Arc>, logger: Arc, @@ -107,7 +108,8 @@ pub struct Bolt12Payment { impl Bolt12Payment { pub(crate) fn new( runtime: Arc, channel_manager: Arc, - keys_manager: Arc, payment_store: Arc, config: Arc, + keys_manager: Arc, payment_store: Arc, + outbound_recurrence_store: Arc, config: Arc, is_running: Arc>, logger: Arc, async_payments_role: Option, ) -> Self { @@ -116,6 +118,7 @@ impl Bolt12Payment { channel_manager, keys_manager, payment_store, + outbound_recurrence_store, config, is_running, logger, diff --git a/src/payment/mod.rs b/src/payment/mod.rs index 13dbe51063..7220f50131 100644 --- a/src/payment/mod.rs +++ b/src/payment/mod.rs @@ -13,6 +13,7 @@ mod bolt12; #[cfg(feature = "unified-payments")] mod hrn; mod onchain; +pub(crate) mod outbound_recurrence; pub(crate) mod pending_payment_store; mod spontaneous; pub(crate) mod store; @@ -25,6 +26,10 @@ pub use bolt12::{Bolt12Payment, PayerProofOptions}; #[cfg(feature = "unified-payments")] pub(crate) use hrn::HRNResolver; pub use onchain::OnchainPayment; +pub use outbound_recurrence::{ + OutboundRecurrenceDetails, OutboundRecurrencePaymentState, OutboundRecurrenceStatus, + RecurrenceId, +}; pub(crate) use pending_payment_store::{FundingTxCandidate, PendingPaymentDetails}; pub use spontaneous::SpontaneousPayment; pub use store::{ diff --git a/src/payment/outbound_recurrence.rs b/src/payment/outbound_recurrence.rs new file mode 100644 index 0000000000..b6495882bb --- /dev/null +++ b/src/payment/outbound_recurrence.rs @@ -0,0 +1,215 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license . You may not use this file except in +// accordance with one or both of these licenses. + +use lightning::ln::channelmanager::PaymentId; +use lightning::ln::outbound_payment::Retry; +pub use lightning::offers::invoice_request::RecurrenceId; +use lightning::routing::router::RouteParametersConfig; +use lightning::{impl_ser_tlv_based, impl_ser_tlv_based_enum}; +use lightning_types::string::UntrustedString; + +use crate::data_store::{StorableObject, StorableObjectId, StorableObjectUpdate}; +use crate::hex_utils; + +impl StorableObjectId for RecurrenceId { + fn encode_to_hex_str(&self) -> String { + hex_utils::to_string(&self.0) + } + + fn decode_from_hex_str(s: &str) -> Option { + hex_utils::to_vec(s)?.try_into().ok().map(Self) + } +} + +/// The lifecycle status of an outbound recurrence. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OutboundRecurrenceStatus { + /// Outbound recurrence can accept its next payment attempt. + Active, + /// Current payment window closed before a payment succeeded. + Missed, + /// Outbound recurrence will not accept further payments. + Cancelled, + /// Outbound recurrence reached its configured payment limit. + Completed, +} + +impl_ser_tlv_based_enum!(OutboundRecurrenceStatus, + (0, Active) => {}, + (2, Missed) => {}, + (4, Cancelled) => {}, + (6, Completed) => {}, +); + +/// The payment attempt state of an outbound recurrence. +/// +/// Tracks an in-progress attempt or retains the ID of a failed attempt. This is +/// separate from [`OutboundRecurrenceStatus`]: a failed attempt does not by itself +/// end the outbound recurrence or advance its paid period count. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OutboundRecurrencePaymentState { + /// No attempt is in progress and no failed attempt is retained. + /// + /// Used before the first attempt and after a successful payment, whose ID is + /// recorded in [`OutboundRecurrenceDetails::last_successful_payment_id`]. + NoActivePayment, + /// An attempt with this payment ID is in progress. + /// + /// Another attempt must not be started while this attempt remains active. + Active(PaymentId), + /// The attempt with this payment ID failed and is no longer in progress. + /// + /// Retains the failed ID until another attempt replaces it. A new attempt may + /// be started if the outbound recurrence status and payment window permit it. + Failed(PaymentId), +} + +impl_ser_tlv_based_enum!(OutboundRecurrencePaymentState, + (0, NoActivePayment) => {}, + {2, Active} => (), + {4, Failed} => (), +); + +/// Persisted state for an outbound recurrence, where this node is the payer. +/// +/// Stored using [`RecurrenceId`] as the key. Lookup and list APIs return owned copies; +/// changing a returned value does not update the stored recurrence. +#[derive(Clone, Debug)] +pub struct OutboundRecurrenceDetails { + /// Recurrence ID. + pub id: RecurrenceId, + + /// Current lifecycle status of the outbound recurrence. + pub status: OutboundRecurrenceStatus, + + /// State of the outbound recurrence's payment attempt. + /// + /// Retains the payment ID while an attempt is active or after it fails. + /// Successful attempts are recorded in [`Self::last_successful_payment_id`]. + pub payment_state: OutboundRecurrencePaymentState, + + /// Serialized original offer defining the outbound recurrence terms. + /// + /// Retained to ensure that subsequent payments use the same offer. + pub original_offer: Vec, + + /// Amount in millisatoshis to use for each payment by default. + /// + /// May be increased by the user before the next period begins. If the original + /// offer specifies an amount, this must be at least that amount multiplied by + /// [`Self::quantity`], when a quantity is set. + pub amount_msat: u64, + + /// Quantity to include in each invoice request, if any. + /// + /// May be changed by the user before the next period begins. + pub quantity: Option, + + /// Note to include in each invoice request. + pub payer_note: Option, + + /// UNIX timestamp anchoring period zero of the outbound recurrence. + /// + /// If the offer does not specify a basetime, period zero is anchored to + /// the creation time of the first successfully paid invoice. In that case, + /// this is `None` until the first payment succeeds and `Some` thereafter. + pub basetime: Option, + + /// First outbound recurrence period to pay, if specified. + pub initial_start: Option, + + /// Number of outbound recurrence periods successfully paid. + pub paid_count: u32, + + /// Opaque state provided by the payee for the next invoice request. + pub opaque_state: Option>, + + /// Retry strategy to use when attempting each payment. + pub retry_policy: Retry, + + /// Routing configuration for this outbound recurrence overriding the node-wide default. + pub routing_override: Option, + + /// Payment ID of the most recently completed payment. + pub last_successful_payment_id: Option, + + /// Whether future outbound recurrence periods should be paid automatically. + pub pay_next_automatically: bool, +} + +impl Default for OutboundRecurrenceDetails { + fn default() -> Self { + Self { + id: RecurrenceId([0; 32]), + status: OutboundRecurrenceStatus::Active, + payment_state: OutboundRecurrencePaymentState::NoActivePayment, + original_offer: Vec::new(), + amount_msat: 0, + quantity: None, + payer_note: None, + basetime: None, + initial_start: None, + paid_count: 0, + opaque_state: None, + retry_policy: Retry::Attempts(0), + routing_override: None, + last_successful_payment_id: None, + pay_next_automatically: false, + } + } +} + +impl_ser_tlv_based!(OutboundRecurrenceDetails, { + (0, id, required), + (2, status, required), + (4, payment_state, required), + (6, original_offer, required), + (8, amount_msat, required), + (10, quantity, option), + (12, payer_note, option), + (14, basetime, option), + (16, initial_start, option), + (18, paid_count, required), + (20, opaque_state, option), + (22, retry_policy, required), + (24, routing_override, option), + (26, last_successful_payment_id, option), + (28, pay_next_automatically, required), +}); + +impl StorableObject for OutboundRecurrenceDetails { + type Id = RecurrenceId; + type Update = OutboundRecurrenceDetailsUpdate; + + fn id(&self) -> Self::Id { + self.id + } + + fn update(&mut self, update: Self::Update) -> bool { + if self.id() != update.details.id() { + return false; + } + *self = update.details; + true + } + + fn to_update(&self) -> Self::Update { + OutboundRecurrenceDetailsUpdate { details: self.clone() } + } +} + +/// Update instructions for [`OutboundRecurrenceDetails`]. +#[derive(Clone, Debug)] +pub(crate) struct OutboundRecurrenceDetailsUpdate { + pub details: OutboundRecurrenceDetails, +} + +impl StorableObjectUpdate for OutboundRecurrenceDetailsUpdate { + fn id(&self) -> RecurrenceId { + self.details.id + } +} diff --git a/src/types.rs b/src/types.rs index 7297db3a5c..bd52e67bb8 100644 --- a/src/types.rs +++ b/src/types.rs @@ -42,6 +42,7 @@ use crate::fee_estimator::OnchainFeeEstimator; use crate::ffi::maybe_wrap; use crate::logger::Logger; use crate::message_handler::NodeCustomMessageHandler; +use crate::payment::outbound_recurrence::OutboundRecurrenceDetails; use crate::payment::{PaymentDetails, PendingPaymentDetails}; use crate::runtime::RuntimeSpawner; @@ -334,6 +335,9 @@ pub(crate) type BumpTransactionEventHandler = pub(crate) type PaymentStore = DataStore, KeepLeastRecentlyUsed>; +pub(crate) type OutboundRecurrenceStore = + DataStore, KeepAllEntries>; + /// A local, potentially user-provided, identifier of a channel. /// /// By default, this will be randomly generated for the user to ensure local uniqueness. From b79fdade9c44fc38b7da918cab26ec2fc624a3e7 Mon Sep 17 00:00:00 2001 From: shaavan Date: Sun, 6 Sep 2026 21:35:28 +0530 Subject: [PATCH 3/7] [feat] Introduce initiate_outbound_recurrence Add an API to start an outbound recurrence by submitting its initial invoice request. Validate quantity against the offer and calculate its minimum total with checked multiplication. Reserve the recurrence and initial payment before submission, rejecting existing IDs rather than overwriting their records. Retain submission failures without advancing paid progress. Failure updates only affect the reserved attempt and preserve completed payments. Acquire recurrence-store locks before payment-store locks. Co-Authored-By: GLaDOS --- src/payment/bolt12.rs | 243 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 239 insertions(+), 4 deletions(-) diff --git a/src/payment/bolt12.rs b/src/payment/bolt12.rs index 213c5998df..5c9d442228 100644 --- a/src/payment/bolt12.rs +++ b/src/payment/bolt12.rs @@ -14,9 +14,12 @@ use std::sync::{Arc, RwLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use lightning::blinded_path::message::BlindedMessagePath; -use lightning::ln::channelmanager::{OptionalOfferPaymentParams, PaymentId}; +use lightning::ln::channelmanager::{ + OptionalOfferPaymentParams, PaymentId, RecurrencePaymentParams, +}; use lightning::ln::outbound_payment::Retry; -use lightning::offers::offer::{Amount, Offer as LdkOffer, OfferFromHrn, Quantity}; +use lightning::offers::invoice_request::RecurrenceId; +use lightning::offers::offer::{Amount, Offer as LdkOffer, OfferFromHrn, Quantity, RecurrenceType}; use lightning::offers::parse::Bolt12SemanticError; use lightning::offers::payer_proof::PaidBolt12Invoice as LdkPaidBolt12Invoice; #[cfg(not(feature = "uniffi"))] @@ -24,15 +27,22 @@ use lightning::offers::payer_proof::PayerProof as LdkPayerProof; use lightning::routing::router::RouteParametersConfig; use lightning::sign::{EntropySource, NodeSigner}; #[cfg(feature = "uniffi")] -use lightning::util::ser::{Readable, Writeable}; +use lightning::util::ser::Readable; +use lightning::util::ser::Writeable; use lightning_types::payment::PaymentPreimage; use lightning_types::string::UntrustedString; use crate::config::{AsyncPaymentsRole, Config, LDK_PAYMENT_RETRY_TIMEOUT}; +use crate::data_store::StorableObject; use crate::error::Error; use crate::ffi::{maybe_deref, maybe_wrap}; use crate::logger::{log_error, log_info, LdkLogger, Logger}; -use crate::payment::store::{PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus}; +use crate::payment::outbound_recurrence::{ + OutboundRecurrenceDetails, OutboundRecurrencePaymentState, OutboundRecurrenceStatus, +}; +use crate::payment::store::{ + PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, +}; use crate::runtime::Runtime; use crate::types::{ChannelManager, KeysManager, OutboundRecurrenceStore, PaymentStore}; @@ -126,6 +136,231 @@ impl Bolt12Payment { } } + /// Registers an outbound recurrence and submits its initial invoice request. + /// + /// The returned [`RecurrenceId`] identifies the complete recurring relationship and must be + /// retained for its lifetime. The returned [`PaymentId`] identifies only the initial payment + /// attempt. Both records are reserved after validation and persisted before submitting the + /// request. A submission failure retains the failed attempt without advancing paid progress. + /// + /// `quantity` must be supplied exactly when the offer expects it and must satisfy its bounds. + /// `amount_msat` is the total for one period. When omitted, it defaults to the offer's per-item + /// amount multiplied by the quantity (or one when no quantity is expected). Invalid quantities + /// return [`Error::InvalidQuantity`]; missing, zero, overflowing, excessive, or insufficient + /// amounts return [`Error::InvalidAmount`] before any records are reserved. + pub fn initiate_outbound_recurrence( + &self, offer: &Offer, amount_msat: Option, quantity: Option, + payer_note: Option, route_parameters: Option, + initial_start: Option, + ) -> Result<(RecurrenceId, PaymentId), Error> { + if !*self.is_running.read().expect("lock") { + return Err(Error::NotRunning); + } + + let offer = maybe_deref(offer); + let recurrence = offer.offer_recurrence().ok_or(Error::InvoiceRequestCreationFailed)?; + if recurrence.period_index(0, initial_start).is_err() { + return Err(Error::InvoiceRequestCreationFailed); + } + + // A quantity must be present exactly when the offer requests it. Let the offer enforce + // its bounds rather than accepting zero, an omitted quantity, or an unexpected one. + match quantity { + None if offer.expects_quantity() => return Err(Error::InvalidQuantity), + Some(qty) if !offer.expects_quantity() || !offer.is_valid_quantity(qty) => { + return Err(Error::InvalidQuantity); + }, + _ => {}, + } + + // Offer amounts are per item; the caller's amount is the total for this period. + // Check multiplication even when the caller supplies a total, so overflow cannot bypass + // the minimum required by the offer. + let minimum_amount_msat = match offer.amount() { + Some(Amount::Bitcoin { amount_msats }) => { + Some(amount_msats.checked_mul(quantity.unwrap_or(1)).ok_or(Error::InvalidAmount)?) + }, + Some(_) => return Err(Error::UnsupportedCurrency), + None => None, + }; + let amount_msat = amount_msat.or(minimum_amount_msat).ok_or(Error::InvalidAmount)?; + if amount_msat == 0 + || amount_msat > bitcoin::Amount::MAX_MONEY.to_sat() * 1_000 + || minimum_amount_msat.is_some_and(|minimum| amount_msat < minimum) + { + return Err(Error::InvalidAmount); + } + + let recurrence_id = RecurrenceId(self.keys_manager.get_secure_random_bytes()); + let payment_id = PaymentId(self.keys_manager.get_secure_random_bytes()); + let retry_policy = Retry::Timeout(LDK_PAYMENT_RETRY_TIMEOUT); + + // An offer with an explicit basetime fixes the basetime expected in the first invoice. + // Otherwise, the first invoice's creation time establishes it. + let offer_basetime = match recurrence.recurrence_type { + RecurrenceType::Compulsory(Some(base)) => Some(base.basetime), + _ => None, + }; + + let details = OutboundRecurrenceDetails { + id: recurrence_id, + status: OutboundRecurrenceStatus::Active, + payment_state: OutboundRecurrencePaymentState::Active(payment_id), + original_offer: offer.encode(), + amount_msat, + quantity, + payer_note: payer_note.clone().map(UntrustedString), + basetime: offer_basetime, + initial_start, + paid_count: 0, + opaque_state: None, + retry_policy, + routing_override: route_parameters, + last_successful_payment_id: None, + pay_next_automatically: false, + }; + let kind = PaymentKind::Bolt12Offer { + hash: None, + preimage: None, + secret: None, + offer_id: offer.id(), + payer_note: payer_note.map(UntrustedString), + quantity, + }; + let payment = PaymentDetails::new( + payment_id, + kind, + Some(amount_msat), + None, + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + self.reserve_initial_recurrence(details, payment)?; + + self.runtime + .block_on(self.submit_reserved_outbound_recurrence(recurrence_id, payment_id))?; + Ok((recurrence_id, payment_id)) + } + + /// Reserves both initial records without replacing an existing recurrence or payment. + /// + /// Always acquire the recurrence store's mutation lock before the payment store's lock. + /// Submission may proceed only after both writes succeed. The stores are not transactional: + /// if the recurrence write fails, the payment can remain persisted, but no request is sent. + fn reserve_initial_recurrence( + &self, details: OutboundRecurrenceDetails, payment: PaymentDetails, + ) -> Result<(), Error> { + let recurrence_id = details.id; + self.runtime.block_on(self.outbound_recurrence_store.mutate_async( + &recurrence_id, + |current| async move { + if current.is_some() { + return Err(Error::DuplicatePayment); + } + let payment_id = payment.id; + self.payment_store + .mutate(&payment_id, |current| current.is_none().then_some(payment)) + .await? + .ok_or(Error::DuplicatePayment)?; + Ok(Some(details)) + }, + ))?; + Ok(()) + } + + /// Submits a persisted attempt only while it still owns an active recurrence. + /// + /// Hold the recurrence mutation lock through LDK's synchronous queueing call. Other + /// lifecycle mutations must use this same lock, so a cancellation that wins the lock can + /// prevent an unqueued reservation from being submitted. Read parameters from the current + /// record rather than the caller's earlier snapshot. Acquire payment-store locks second. + async fn submit_reserved_outbound_recurrence( + &self, recurrence_id: RecurrenceId, payment_id: PaymentId, + ) -> Result { + let mut submission_result = Ok(payment_id); + let result = &mut submission_result; + self.outbound_recurrence_store + .mutate_async(&recurrence_id, |current| async move { + let mut details = current.ok_or(Error::InvalidRecurrenceId)?; + if details.status != OutboundRecurrenceStatus::Active + || details.payment_state != OutboundRecurrencePaymentState::Active(payment_id) + { + return Err(Error::PaymentSendingFailed); + } + // Both reservations must still exist. In particular, never submit again after + // an outcome has been persisted but its recurrence update is still pending. + match self.payment_store.get(&payment_id).await? { + Some(payment) if payment.status == PaymentStatus::Pending => {}, + _ => return Err(Error::PaymentSendingFailed), + } + + let offer = LdkOffer::try_from(details.original_offer.clone()) + .map_err(|_| Error::InvalidOffer)?; + let params = RecurrencePaymentParams { + counter: details.paid_count, + start: details.initial_start, + prev_state: details.opaque_state.clone(), + quantity: details.quantity, + expected_invoice_recurrence_basetime: details.basetime, + }; + let optional_params = OptionalOfferPaymentParams { + payer_note: details.payer_note.as_ref().map(|note| note.0.clone()), + route_params_config: details.routing_override.unwrap_or_default(), + retry_strategy: details.retry_policy, + }; + match self.channel_manager.pay_for_recurrence( + &offer, + Some(details.amount_msat), + payment_id, + recurrence_id, + params, + optional_params, + ) { + Ok(()) => { + log_info!( + self.logger, + "Initiated sending {}msat to {:?}", + details.amount_msat, + offer.issuer_signing_pubkey() + ); + // Reservation already persisted the active attempt. Avoid another write + // after queueing, when the request may already produce an event. + Ok(None) + }, + Err(e) => { + log_error!(self.logger, "Failed to send invoice request: {:?}", e); + *result = Err(match e { + Bolt12SemanticError::DuplicatePaymentId => Error::DuplicatePayment, + _ => Error::InvoiceRequestCreationFailed, + }); + let payment = self + .payment_store + .mutate(&payment_id, |current| { + let mut payment = match current { + Some(payment) if payment.status != PaymentStatus::Succeeded => { + payment.clone() + }, + _ => return None, + }; + let mut update = PaymentDetailsUpdate::new(payment_id); + update.status = Some(PaymentStatus::Failed); + payment.update(update); + Some(payment) + }) + .await?; + // A success or removal may have raced with submission. Preserve it. + if payment.is_none() { + return Ok(None); + } + details.payment_state = OutboundRecurrencePaymentState::Failed(payment_id); + Ok(Some(details)) + }, + } + }) + .await?; + submission_result + } + pub(crate) fn send_using_amount_inner( &self, offer: &Offer, amount_msat: u64, quantity: Option, payer_note: Option, route_parameters: Option, hrn: Option, From e3437fceb3dc118e10b12aaa6dc52e72d28d5f7a Mon Sep 17 00:00:00 2001 From: shaavan Date: Fri, 9 Oct 2026 19:08:39 +0530 Subject: [PATCH 4/7] [feat] Introduce manual recurrence payment logic Allow callers to submit subsequent payments using the stored recurring offer and payment parameters. Require an active recurrence, a successful initial payment, no active attempt and an open payment window. Validate the next period against the inclusive recurrence limit. After submission, persist the payment record and associate its ID with the recurrence for event handling. Return the payment ID on successful submission and an error on synchronous failure. Submission failures other than duplicate payment IDs are recorded as failed payments, while the recurrence attempt is marked active. Co-Authored-By: GLaDOS --- bindings/ldk_node.udl | 1 + src/error.rs | 5 ++ src/payment/bolt12.rs | 187 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 179 insertions(+), 14 deletions(-) diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index 2cf1b0f976..1ca3212fe8 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -177,6 +177,7 @@ enum NodeError { "InvalidOfferId", "InvalidNodeId", "InvalidRecurrenceId", + "InvalidOutboundRecurrence", "InvalidPaymentId", "InvalidPaymentHash", "InvalidPaymentPreimage", diff --git a/src/error.rs b/src/error.rs index 876e2d57ec..52f5836f39 100644 --- a/src/error.rs +++ b/src/error.rs @@ -89,6 +89,8 @@ pub enum Error { InvalidNodeId, /// The given recurrence id is invalid. InvalidRecurrenceId, + /// The outbound recurrence is not in a valid state for this operation. + InvalidOutboundRecurrence, /// The given payment id is invalid. InvalidPaymentId, /// The given payment hash is invalid. @@ -196,6 +198,9 @@ impl fmt::Display for Error { Self::InvalidOfferId => write!(f, "The given offer id is invalid."), Self::InvalidNodeId => write!(f, "The given node id is invalid."), Self::InvalidRecurrenceId => write!(f, "The given recurrence id is invalid."), + Self::InvalidOutboundRecurrence => { + write!(f, "The outbound recurrence is not in a valid state for this operation.") + }, Self::InvalidPaymentId => write!(f, "The given payment id is invalid."), Self::InvalidPaymentHash => write!(f, "The given payment hash is invalid."), Self::InvalidPaymentPreimage => write!(f, "The given payment preimage is invalid."), diff --git a/src/payment/bolt12.rs b/src/payment/bolt12.rs index 5c9d442228..4269fd7d44 100644 --- a/src/payment/bolt12.rs +++ b/src/payment/bolt12.rs @@ -19,7 +19,9 @@ use lightning::ln::channelmanager::{ }; use lightning::ln::outbound_payment::Retry; use lightning::offers::invoice_request::RecurrenceId; -use lightning::offers::offer::{Amount, Offer as LdkOffer, OfferFromHrn, Quantity, RecurrenceType}; +use lightning::offers::offer::{ + Amount, Offer as LdkOffer, OfferFromHrn, Quantity, Recurrence, RecurrenceType, +}; use lightning::offers::parse::Bolt12SemanticError; use lightning::offers::payer_proof::PaidBolt12Invoice as LdkPaidBolt12Invoice; #[cfg(not(feature = "uniffi"))] @@ -308,14 +310,31 @@ impl Bolt12Payment { route_params_config: details.routing_override.unwrap_or_default(), retry_strategy: details.retry_policy, }; - match self.channel_manager.pay_for_recurrence( - &offer, - Some(details.amount_msat), - payment_id, - recurrence_id, - params, - optional_params, - ) { + // Persistence can outlast the payment window. Recheck immediately before + // queueing and record an expired reservation as failed without sending it. + let request_result = (|| { + if details.paid_count > 0 { + let recurrence = offer.offer_recurrence().ok_or(Error::InvalidOffer)?; + self.validate_outbound_recurrence_payment_window(&details, &recurrence)?; + } + self.channel_manager + .pay_for_recurrence( + &offer, + Some(details.amount_msat), + payment_id, + recurrence_id, + params, + optional_params, + ) + .map_err(|e| { + log_error!(self.logger, "Failed to send invoice request: {:?}", e); + match e { + Bolt12SemanticError::DuplicatePaymentId => Error::DuplicatePayment, + _ => Error::InvoiceRequestCreationFailed, + } + }) + })(); + match request_result { Ok(()) => { log_info!( self.logger, @@ -328,11 +347,7 @@ impl Bolt12Payment { Ok(None) }, Err(e) => { - log_error!(self.logger, "Failed to send invoice request: {:?}", e); - *result = Err(match e { - Bolt12SemanticError::DuplicatePaymentId => Error::DuplicatePayment, - _ => Error::InvoiceRequestCreationFailed, - }); + *result = Err(e); let payment = self .payment_store .mutate(&payment_id, |current| { @@ -361,6 +376,150 @@ impl Bolt12Payment { submission_result } + /// Submits the next sequential payment for an active outbound recurrence. + /// + /// The initial payment must have succeeded, no payment may be in progress, and the next + /// period's payment window must be open. Failed attempts may be retried within that window. + /// + /// Returns the [`PaymentId`] when the invoice request is successfully submitted. This does + /// not indicate that the payment has succeeded; inspect its payment record for the outcome. + /// Synchronous submission failures return an error. + pub fn pay_next_outbound_recurrence( + &self, recurrence_id: RecurrenceId, + ) -> Result { + if !*self.is_running.read().expect("lock") { + return Err(Error::NotRunning); + } + + let details = self + .runtime + .block_on(self.outbound_recurrence_store.get(&recurrence_id))? + .ok_or(Error::InvalidRecurrenceId)?; + + // Sanity Checks + if details.status != OutboundRecurrenceStatus::Active { + log_error!(self.logger, "Cannot pay the next recurrence: recurrence is not active."); + return Err(Error::InvalidOutboundRecurrence); + } + + if matches!(details.payment_state, OutboundRecurrencePaymentState::Active(_)) { + log_error!( + self.logger, + "Cannot pay the next recurrence: a payment is already active for the current period." + ); + return Err(Error::PaymentSendingFailed); + } + + if details.paid_count == 0 { + log_error!( + self.logger, + "Cannot pay the next recurrence before the initial payment succeeds." + ); + return Err(Error::InvalidOutboundRecurrence); + } + + self.runtime.block_on(self.submit_outbound_recurrence_payment(details)) + } + + /// Reserves a subsequent attempt before using the shared submission gate. + /// + /// Recheck the caller's period and attempt under the recurrence mutation lock so concurrent + /// calls cannot both reserve the same period or replace a newer attempt. As with initial + /// reservation, acquire payment-store locks second and never overwrite an existing payment. + async fn submit_outbound_recurrence_payment( + &self, expected: OutboundRecurrenceDetails, + ) -> Result { + let recurrence_id = expected.id; + let payment_id = PaymentId(self.keys_manager.get_secure_random_bytes()); + self.outbound_recurrence_store + .mutate_async(&recurrence_id, |current| async move { + let mut details = current.ok_or(Error::InvalidRecurrenceId)?; + if details.status != OutboundRecurrenceStatus::Active + || details.paid_count != expected.paid_count + || details.payment_state != expected.payment_state + { + return Err(Error::InvalidOutboundRecurrence); + } + match details.payment_state { + OutboundRecurrencePaymentState::Active(_) => { + return Err(Error::PaymentSendingFailed) + }, + OutboundRecurrencePaymentState::Failed(previous_id) => { + // A recurrence failure may be persisted before the payment outcome. Wait + // for that outcome instead of replacing an incompletely handled attempt. + match self.payment_store.get(&previous_id).await? { + Some(payment) if payment.status != PaymentStatus::Failed => { + return Err(Error::PaymentSendingFailed); + }, + _ => {}, + } + }, + OutboundRecurrencePaymentState::NoActivePayment => {}, + } + let offer = LdkOffer::try_from(details.original_offer.clone()) + .map_err(|_| Error::InvalidOffer)?; + let recurrence = offer.offer_recurrence().ok_or(Error::InvalidOffer)?; + self.validate_outbound_recurrence_payment_window(&details, &recurrence)?; + + let payment = PaymentDetails::new( + payment_id, + PaymentKind::Bolt12Offer { + hash: None, + preimage: None, + secret: None, + offer_id: offer.id(), + payer_note: details.payer_note.clone(), + quantity: details.quantity, + }, + Some(details.amount_msat), + None, + PaymentDirection::Outbound, + PaymentStatus::Pending, + ); + self.payment_store + .mutate(&payment_id, |current| current.is_none().then_some(payment)) + .await? + .ok_or(Error::DuplicatePayment)?; + details.payment_state = OutboundRecurrencePaymentState::Active(payment_id); + Ok(Some(details)) + }) + .await?; + self.submit_reserved_outbound_recurrence(recurrence_id, payment_id).await + } + + /// Checks the next outbound period against the offer limit and its payment window. + fn validate_outbound_recurrence_payment_window( + &self, details: &OutboundRecurrenceDetails, recurrence: &Recurrence, + ) -> Result<(), Error> { + let period_index = recurrence + .period_index(details.paid_count, details.initial_start) + .map_err(|_| Error::InvalidOffer)?; + + if recurrence.recurrence_limit.map_or(false, |limit| period_index > limit.0) { + debug_assert!(false, "Recurrence exceeded its limit without being marked completed."); + return Err(Error::InvalidOutboundRecurrence); + } + + let basetime = details.basetime.ok_or(Error::InvalidOffer)?; + + let (opening, closing) = + recurrence.payment_window(basetime, period_index).map_err(|_| Error::InvalidOffer)?; + + let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + + if now < opening { + log_error!(self.logger, "Tried to send recurrence payment too early."); + return Err(Error::PaymentSendingFailed); + } + + if now >= closing { + // TODO: Figure out, where we should ideally be changing recurrence states at appropriate times. + return Err(Error::InvalidOutboundRecurrence); + } + + Ok(()) + } + pub(crate) fn send_using_amount_inner( &self, offer: &Offer, amount_msat: u64, quantity: Option, payer_note: Option, route_parameters: Option, hrn: Option, From 8c07d259a2167bea226a5d18ba8e4f7a4a923714 Mon Sep 17 00:00:00 2001 From: shaavan Date: Sat, 10 Oct 2026 19:39:18 +0530 Subject: [PATCH 5/7] [feat] Add manual retry support for outbound recurrence payments Failed initial payments cannot be retried through pay_next_outbound_recurrence, which requires the first payment to have succeeded. Add retry_outbound_recurrence for active recurrences with a failed attempt. Reuse reservation and submission without advancing the recurrence period. Validate the payment window before reservation and again before queueing, including initial retries with an explicit basetime. Permit an unknown window only when the first payment has not established basetime and the offer does not specify one. Co-Authored-By: GLaDOS --- src/payment/bolt12.rs | 56 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/src/payment/bolt12.rs b/src/payment/bolt12.rs index 4269fd7d44..f20990db88 100644 --- a/src/payment/bolt12.rs +++ b/src/payment/bolt12.rs @@ -313,10 +313,10 @@ impl Bolt12Payment { // Persistence can outlast the payment window. Recheck immediately before // queueing and record an expired reservation as failed without sending it. let request_result = (|| { - if details.paid_count > 0 { - let recurrence = offer.offer_recurrence().ok_or(Error::InvalidOffer)?; - self.validate_outbound_recurrence_payment_window(&details, &recurrence)?; - } + let recurrence = offer.offer_recurrence().ok_or(Error::InvalidOffer)?; + // Initial retries with a known basetime also have a payment window. The + // validator permits an unknown window only before basetime is established. + self.validate_outbound_recurrence_payment_window(&details, &recurrence)?; self.channel_manager .pay_for_recurrence( &offer, @@ -421,6 +421,40 @@ impl Bolt12Payment { self.runtime.block_on(self.submit_outbound_recurrence_payment(details)) } + /// Retries a failed payment attempt without advancing the recurrence period. + /// + /// The recurrence must be active and its latest attempt must have failed. Its payment + /// window must be open, except for an initial payment whose offer has no explicit basetime: + /// that window is not known until the first payment succeeds. + /// + /// Returns a new [`PaymentId`] when the invoice request is successfully submitted, not when + /// the payment succeeds. Returns [`Error::NotRunning`] if the node is stopped, + /// [`Error::InvalidRecurrenceId`] if the recurrence is unknown, or + /// [`Error::InvalidOutboundRecurrence`] if its lifecycle or attempt state does not permit a retry. + /// Payment-window, submission, and persistence errors are returned to the caller. + pub fn retry_outbound_recurrence( + &self, recurrence_id: RecurrenceId, + ) -> Result { + if !*self.is_running.read().expect("lock") { + return Err(Error::NotRunning); + } + + let details = self + .runtime + .block_on(self.outbound_recurrence_store.get(&recurrence_id))? + .ok_or(Error::InvalidRecurrenceId)?; + + // A retry replaces only a failed attempt; it must not start a new period or replace + // an in-progress payment. The shared submission path preserves the period counter. + if details.status != OutboundRecurrenceStatus::Active + || !matches!(details.payment_state, OutboundRecurrencePaymentState::Failed(_)) + { + return Err(Error::InvalidOutboundRecurrence); + } + + self.runtime.block_on(self.submit_outbound_recurrence_payment(details)) + } + /// Reserves a subsequent attempt before using the shared submission gate. /// /// Recheck the caller's period and attempt under the recurrence mutation lock so concurrent @@ -500,7 +534,19 @@ impl Bolt12Payment { return Err(Error::InvalidOutboundRecurrence); } - let basetime = details.basetime.ok_or(Error::InvalidOffer)?; + // Without an explicit basetime, the first successful invoice establishes the window. + let basetime = match details.basetime { + Some(basetime) => basetime, + None if details.paid_count == 0 + && !matches!(recurrence.recurrence_type, RecurrenceType::Compulsory(Some(_))) => + { + // If the offer has no basetime and the first payment has not succeeded, + // period zero's start time and payment window are not known yet. + // Skip the window check so a failed first payment can be retried. + return Ok(()); + }, + None => return Err(Error::InvalidOffer), + }; let (opening, closing) = recurrence.payment_window(basetime, period_index).map_err(|_| Error::InvalidOffer)?; From 7584b992305658c448c621ed3dd971b108b77a52 Mon Sep 17 00:00:00 2001 From: shaavan Date: Mon, 28 Sep 2026 19:35:33 +0530 Subject: [PATCH 6/7] [feat] Track outbound recurrence payment outcomes Recurring payments need persisted outcomes so callers can determine whether to retry an attempt or pay the next period after restart. On success, retain the payment ID, advance paid progress, establish the basetime, and save the payee's opaque state for the next request. Clear the active attempt and mark the recurrence completed when its limit is reached. On failure, retain the failed payment ID without advancing paid progress. Match outcomes to the active payment ID and recheck ownership under the mutation lock so replayed events cannot overwrite a newer attempt. Request event replay when persistence fails. Co-Authored-By: GLaDOS --- src/event.rs | 203 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 200 insertions(+), 3 deletions(-) diff --git a/src/event.rs b/src/event.rs index 75fe5438ef..6749ef5c87 100644 --- a/src/event.rs +++ b/src/event.rs @@ -24,6 +24,8 @@ use lightning::events::{ }; use lightning::ln::channelmanager::{PaymentId, TrustedChannelFeatures}; use lightning::ln::types::ChannelId; +use lightning::offers::invoice::Bolt12Invoice; +use lightning::offers::offer::Offer; use lightning::routing::gossip::NodeId; use lightning::sign::EntropySource; use lightning::util::config::{ChannelConfigOverrides, ChannelConfigUpdate}; @@ -36,7 +38,7 @@ use lightning_types::payment::{PaymentHash, PaymentPreimage}; use crate::config::{may_announce_channel, Config, PEER_RECONNECTION_INTERVAL}; use crate::connection::ConnectionManager; -use crate::data_store::DataStoreUpdateResult; +use crate::data_store::{DataStoreUpdateResult, StorableObject}; use crate::fee_estimator::ConfirmationTarget; #[cfg(feature = "uniffi")] use crate::ffi::PaidBolt12Invoice; @@ -48,6 +50,9 @@ use crate::liquidity::LiquiditySource; use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use crate::payment::asynchronous::om_mailbox::OnionMessageMailbox; use crate::payment::asynchronous::static_invoice_store::StaticInvoiceStore; +use crate::payment::outbound_recurrence::{ + OutboundRecurrencePaymentState, OutboundRecurrenceStatus, +}; use crate::payment::store::{ PaymentDetails, PaymentDetailsUpdate, PaymentDirection, PaymentKind, PaymentStatus, }; @@ -694,6 +699,160 @@ where }) } + /// Processes success for the retained outbound attempt without reopening terminal state. + /// + /// The payment record is persisted first by the caller. Replay must still advance an owned + /// attempt even when that payment is already marked successful. Clearing its ownership and + /// retaining the successful ID in one write prevents double counting and stale updates. + async fn advance_outbound_recurrence_on_payment_sent( + &self, payment_id: PaymentId, invoice: &Bolt12Invoice, + ) -> Result<(), ReplayEvent> { + let mut matches = self + .outbound_recurrence_store + .list_filter(|details| { + matches!(details.payment_state, + OutboundRecurrencePaymentState::Active(id) | OutboundRecurrencePaymentState::Failed(id) + if id == payment_id) + }) + .await + .into_iter(); + + let Some(details) = matches.next() else { + // Ordinary payments have no recurrence state. + return Ok(()); + }; + + if matches.len() != 0 { + debug_assert!(false, "multiple recurrences have the same active payment ID?"); + return Ok(()); + } + + let Some(invoice_recurrence) = invoice.invoice_recurrence() else { + debug_assert!(false, "Invoice that should be corresponding to recurrence according to our recurrence store doesn't?"); + return Ok(()); + }; + + match self + .outbound_recurrence_store + .mutate_async(&details.id, |current| async move { + let Some(mut updated) = current else { + log_error!( + self.logger, + "Recurrence disappeared before its payment update was stored" + ); + return Err(Error::InvalidRecurrenceId); + }; + // A late success may follow failure or cancellation. Only the retained attempt + // owns this period; a replay or an older attempt must not advance a newer one. + if updated.last_successful_payment_id == Some(payment_id) + || !matches!(updated.payment_state, + OutboundRecurrencePaymentState::Active(id) | OutboundRecurrencePaymentState::Failed(id) + if id == payment_id) + { + return Ok(None); + } + + let offer = Offer::try_from(updated.original_offer.clone()) + .map_err(|_| Error::InvalidOffer)?; + let recurrence = offer.offer_recurrence().ok_or(Error::InvalidOffer)?; + // paid_count still identifies the period just paid. Looking up the next period + // would complete early at an inclusive limit and may overflow a valid final index. + let paid_period_index = recurrence + .period_index(updated.paid_count, updated.initial_start) + .map_err(|_| Error::InvalidOffer)?; + updated.paid_count = + updated.paid_count.checked_add(1).ok_or(Error::InvalidOutboundRecurrence)?; + updated.basetime.get_or_insert(invoice_recurrence.recurrence_basetime()); + updated.opaque_state = + invoice_recurrence.recurrence_next_state().map(|state| state.to_vec()); + updated.last_successful_payment_id = Some(payment_id); + updated.payment_state = OutboundRecurrencePaymentState::NoActivePayment; + + // Late success records progress while preserving Cancelled, Missed, or Completed. + if updated.status == OutboundRecurrenceStatus::Active + && recurrence + .recurrence_limit + .map(|limit| paid_period_index >= limit.0) + .unwrap_or(false) + { + updated.status = OutboundRecurrenceStatus::Completed; + } + Ok(Some(updated)) + }) + .await + { + Ok(_) => Ok(()), + Err(e) => { + log_error!(self.logger, "Failed to store updated recurrence: {}", e); + Err(ReplayEvent()) + }, + } + } + + /// Marks the unique recurrence with this active payment ID as failed. + /// + /// Leaves unrelated or already-handled attempts unchanged and requests event replay on + /// persistence failure. Returns whether success superseded the failure, in which case the + /// caller must suppress its failure event. Payment-store locks are acquired second. + async fn update_outbound_recurrence_on_payment_failed( + &self, payment_id: PaymentId, + ) -> Result { + let recurrence_id = match self + .outbound_recurrence_store + .list_filter(|details| { + matches!(details.payment_state, + OutboundRecurrencePaymentState::Active(id) | OutboundRecurrencePaymentState::Failed(id) + if id == payment_id) + || details.last_successful_payment_id == Some(payment_id) + }) + .await + .as_slice() + { + [] => return Ok(false), + [details] => details.id, + _ => { + debug_assert!(false, "multiple recurrences have the same active payment ID?"); + return Ok(false); + }, + }; + + let mut success_seen = false; + let success_seen_ref = &mut success_seen; + self.outbound_recurrence_store + .mutate_async(&recurrence_id, |current| async move { + let Some(mut updated) = current else { + log_error!( + self.logger, + "Recurrence disappeared before its failed payment update was stored" + ); + return Err(Error::InvalidRecurrenceId); + }; + // Success may have reached either record since the caller persisted this + // failure. Neither a late failure nor replay may downgrade that outcome. + if updated.last_successful_payment_id == Some(payment_id) + || matches!(self.payment_store.get(&payment_id).await?, + Some(payment) if payment.status == PaymentStatus::Succeeded) + { + *success_seen_ref = true; + return Ok(None); + } + // Recheck under the mutation lock so stale or replayed failures + // cannot overwrite an already-handled or newer attempt. + if updated.payment_state != OutboundRecurrencePaymentState::Active(payment_id) { + return Ok(None); + } + updated.payment_state = OutboundRecurrencePaymentState::Failed(payment_id); + Ok(Some(updated)) + }) + .await + .map_err(|e| { + log_error!(self.logger, "Failed to store updated recurrence: {}", e); + ReplayEvent() + })?; + + Ok(success_seen) + } + async fn resolve_inbound_payment_id( &self, event_payment_id: Option, payment_hash: &PaymentHash, ) -> Result<(PaymentId, Option), ReplayEvent> { @@ -1413,6 +1572,8 @@ where ..PaymentDetailsUpdate::new(payment_id) }; + // Persist the payment before releasing the recurrence's attempt. If the + // recurrence write fails, replay must complete it even though this is idempotent. match self.payment_store.update(update).await { Ok(_) => {}, Err(e) => { @@ -1421,6 +1582,17 @@ where }, }; + if let Some(invoice) = + bolt12_invoice.as_ref().and_then(|paid_invoice| paid_invoice.bolt12_invoice()) + { + if let Err(e) = + self.advance_outbound_recurrence_on_payment_sent(payment_id, invoice).await + { + log_error!(self.logger, "Failed to advance recurrence: replaying event"); + return Err(e); + } + } + match self.payment_store.get(&payment_id).await { Ok(Some(payment)) => { let amount_msat = payment.amount_msat.expect( @@ -1477,13 +1649,38 @@ where status: Some(PaymentStatus::Failed), ..PaymentDetailsUpdate::new(payment_id) }; - match self.payment_store.update(update).await { + let mut already_succeeded = false; + // LDK requires failures following PaymentSent to be ignored. Check and update + // atomically so success cannot be overwritten between a lookup and a write. + match self + .payment_store + .mutate(&payment_id, |current| { + let mut payment = match current { + Some(payment) if payment.status == PaymentStatus::Succeeded => { + already_succeeded = true; + return None; + }, + Some(payment) => payment.clone(), + None => return None, + }; + match payment.update(update) { + true => Some(payment), + false => None, + } + }) + .await + { Ok(_) => {}, Err(e) => { log_error!(self.logger, "Failed to access payment store: {}", e); return Err(ReplayEvent()); }, - }; + } + if already_succeeded + || self.update_outbound_recurrence_on_payment_failed(payment_id).await? + { + return Ok(()); + } let event = Event::PaymentFailed { payment_id, payment_hash, reason }; match self.event_queue.add_event(event).await { From 75c10ae7761f519580656e6b82ee5b22a6772017 Mon Sep 17 00:00:00 2001 From: shaavan Date: Sat, 10 Oct 2026 18:17:30 +0530 Subject: [PATCH 7/7] [feat] Add cancellation support for outbound BOLT12 recurrences Cancel an outbound recurrence locally and queue a cancellation request for the payee after a successful payment. Use the same mutation lock as submission so cancellation can prevent an unqueued reservation from being submitted. Abandon an active attempt while retaining its payment ID for late success or failure handling. Read the latest recurrence state under the lock rather than overwriting progress from an earlier snapshot. Persist local cancellation even when remote cancellation queueing fails, then return the queueing error. Co-Authored-By: GLaDOS --- src/payment/bolt12.rs | 66 ++++++++++++++++++++++++++++++ src/payment/outbound_recurrence.rs | 3 +- 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/payment/bolt12.rs b/src/payment/bolt12.rs index f20990db88..9c6083c1d1 100644 --- a/src/payment/bolt12.rs +++ b/src/payment/bolt12.rs @@ -566,6 +566,72 @@ impl Bolt12Payment { Ok(()) } + /// Cancels an outbound recurrence locally and, after the first successful payment, queues a + /// continuity-preserving cancellation request for the payee. + /// + /// Abandons an active attempt but retains its payment ID until the outcome is known. A late + /// success is still recorded without reopening the recurrence. If queueing remote cancellation + /// fails, local cancellation is persisted before returning the queueing error. + pub fn cancel_outbound_recurrence(&self, recurrence_id: RecurrenceId) -> Result<(), Error> { + if !*self.is_running.read().expect("lock") { + return Err(Error::NotRunning); + } + + let mut cancellation_result = Ok(()); + let result = &mut cancellation_result; + self.runtime.block_on(self.outbound_recurrence_store.mutate_async( + &recurrence_id, + |current| async move { + let mut details = current.ok_or(Error::InvalidRecurrenceId)?; + match details.status { + OutboundRecurrenceStatus::Cancelled => { + log_error!(self.logger, "Tried to cancel an already cancelled recurrence."); + return Err(Error::InvalidOutboundRecurrence); + }, + OutboundRecurrenceStatus::Completed => { + log_error!( + self.logger, + "A completed outbound recurrence cannot be cancelled." + ); + return Err(Error::InvalidOutboundRecurrence); + }, + _ => {}, + } + + // Validate stored offer data before abandoning a payment or queueing a message. + let offer = match details.paid_count { + 0 => None, + _ => Some( + LdkOffer::try_from(details.original_offer.clone()) + .map_err(|_| Error::InvalidOffer)?, + ), + }; + // Submission holds this same mutation lock through queueing. If cancellation + // wins, its persisted status blocks the reservation; otherwise abandon the + // queued attempt. Retain its ID since in-flight HTLCs can still settle. + if let OutboundRecurrencePaymentState::Active(payment_id) = details.payment_state { + self.channel_manager.abandon_payment(payment_id); + } + if let Some(offer) = offer { + // The payee authenticates continuity using the latest successfully paid state. + let params = lightning::ln::channelmanager::RecurrenceCancellationParams { + counter: details.paid_count, + start: details.initial_start, + prev_state: details.opaque_state.clone(), + }; + *result = self + .channel_manager + .cancel_recurrence(&offer, recurrence_id, params) + .map_err(|_| Error::PaymentSendingFailed); + } + // Remote queueing failure must not let the local reservation submit afterward. + details.status = OutboundRecurrenceStatus::Cancelled; + Ok(Some(details)) + }, + ))?; + cancellation_result + } + pub(crate) fn send_using_amount_inner( &self, offer: &Offer, amount_msat: u64, quantity: Option, payer_note: Option, route_parameters: Option, hrn: Option, diff --git a/src/payment/outbound_recurrence.rs b/src/payment/outbound_recurrence.rs index b6495882bb..00735d5b35 100644 --- a/src/payment/outbound_recurrence.rs +++ b/src/payment/outbound_recurrence.rs @@ -59,7 +59,8 @@ pub enum OutboundRecurrencePaymentState { NoActivePayment, /// An attempt with this payment ID is in progress. /// - /// Another attempt must not be started while this attempt remains active. + /// Another attempt must not be started while this attempt remains active. Cancellation + /// retains this ID until the outcome is known, since abandoned HTLCs may still succeed. Active(PaymentId), /// The attempt with this payment ID failed and is no longer in progress. ///