From 74ae822cf8fd340777555ca40e79ae1784eb3d8d Mon Sep 17 00:00:00 2001 From: Xalkan Duarte Date: Tue, 16 Dec 2025 16:53:35 -0300 Subject: [PATCH 01/17] support hodl invoices --- openapi.yaml | 101 ++++++++++++++++ src/disk.rs | 28 ++++- src/error.rs | 26 ++++ src/ldk.rs | 323 +++++++++++++++++++++++++++++++++++++++++++++++--- src/main.rs | 5 +- src/routes.rs | 271 +++++++++++++++++++++++++++++++++++++++++- src/utils.rs | 16 ++- 7 files changed, 745 insertions(+), 25 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index cda48b80..676e387f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -649,6 +649,60 @@ paths: application/json: schema: $ref: '#/components/schemas/LNInvoiceResponse' + /invoice/hodl: + post: + tags: + - Invoices + summary: Create a HODL LN invoice + description: Create a BOLT11 invoice with a caller-provided payment hash; settlement is deferred until settle/cancel. + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/InvoiceHodlRequest' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/InvoiceHodlResponse' + /invoice/settle: + post: + tags: + - Invoices + summary: Settle a HODL invoice + description: Claim a held HTLC for a HODL invoice + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/InvoiceSettleRequest' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyResponse' + /invoice/cancel: + post: + tags: + - Invoices + summary: Cancel a HODL invoice + description: Cancel a held HTLC for a HODL invoice + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/InvoiceCancelRequest' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyResponse' /makerexecute: post: tags: @@ -1790,6 +1844,53 @@ components: invoice: type: string example: lnbcrt30u1pjv6yzndqud3jxktt5w46x7unfv9kz6mn0v3jsnp4qdpc280eur52luxppv6f3nnj8l6vnd9g2hnv3qv6mjhmhvlzf6327pp5tjjasx6g9dqptea3fhm6yllq5wxzycnnvp8l6wcq3d6j2uvpryuqsp5l8az8x3g8fe05dg7cmgddld3da09nfjvky8xftwsk4cj8p2l7kfq9qyysgqcqpcxqzdylzlwfnkyw3jv344x4rzwgkk53ng0fhxy5rdduk4g5tpvea8xa6rfckkza35va28xjn2tqkhgarcxep5umm4x5k56wfcdvu95eq7qzp20vrl4xz76syapsa3c09j7lg5gerkaj63llj0ark7ph8hfketn6fkqzm8laf66dhsncm23wkwm5l5377we9e8lnlknnkwje5eefkccusqm6rqt8 + InvoiceHodlRequest: + type: object + required: + - payment_hash + properties: + amt_msat: + type: integer + example: 3000000 + expiry_sec: + type: integer + example: 86400 + asset_id: + type: string + example: rgb:CJkb4YZw-jRiz2sk-~PARPio-wtVYI1c-XAEYCqO-wTfvRZ8 + asset_amount: + type: integer + example: 42 + payment_hash: + type: string + example: 3febfae1e68b190c15461f4c2a3290f9af1dae63fd7d620d2bd61601869026cd + external_ref: + type: string + example: swap-123 + InvoiceHodlResponse: + type: object + properties: + invoice: + type: string + example: lnbcrt30u1pjv6yzndqud3jxktt5w46x7unfv9kz6mn0v3jsnp4qdpc280eur52luxppv6f3nnj8l6vnd9g2hnv3qv6mjhmhvlzf6327pp5tjjasx6g9dqptea3fhm6yllq5wxzycnnvp8l6wcq3d6j2uvpryuqsp5l8az8x3g8fe05dg7cmgddld3da09nfjvky8xftwsk4cj8p2l7kfq9qyysgqcqpcxqzdylzlwfnkyw3jv344x4rzwgkk53ng0fhxy5rdduk4g5tpvea8xa6rfckkza35va28xjn2tqkhgarcxep5umm4x5k56wfcdvu95eq7qzp20vrl4xz76syapsa3c09j7lg5gerkaj63llj0ark7ph8hfketn6fkqzm8laf66dhsncm23wkwm5l5377we9e8lnlknnkwje5eefkccusqm6rqt8 + payment_secret: + type: string + example: 777a7756c620868199ed5fdc35bee4095b5709d543e5c2bf0494396bf27d2ea2 + InvoiceSettleRequest: + type: object + properties: + invoice: + type: string + example: lnbcrt30u1pjv6yzndqud3jxktt5w46x7unfv9kz6mn0v3jsnp4qdpc280eur52luxppv6f3nnj8l6vnd9g2hnv3qv6mjhmhvlzf6327pp5tjjasx6g9dqptea3fhm6yllq5wxzycnnvp8l6wcq3d6j2uvpryuqsp5l8az8x3g8fe05dg7cmgddld3da09nfjvky8xftwsk4cj8p2l7kfq9qyysgqcqpcxqzdylzlwfnkyw3jv344x4rzwgkk53ng0fhxy5rdduk4g5tpvea8xa6rfckkza35va28xjn2tqkhgarcxep5umm4x5k56wfcdvu95eq7qzp20vrl4xz76syapsa3c09j7lg5gerkaj63llj0ark7ph8hfketn6fkqzm8laf66dhsncm23wkwm5l5377we9e8lnlknnkwje5eefkccusqm6rqt8 + payment_preimage: + type: string + example: 777a7756c620868199ed5fdc35bee4095b5709d543e5c2bf0494396bf27d2ea2 + InvoiceCancelRequest: + type: object + properties: + payment_hash: + type: string + example: 3febfae1e68b190c15461f4c2a3290f9af1dae63fd7d620d2bd61601869026cd MakerExecuteRequest: type: object properties: diff --git a/src/disk.rs b/src/disk.rs index 0fc6670d..2818ea63 100644 --- a/src/disk.rs +++ b/src/disk.rs @@ -14,8 +14,8 @@ use std::sync::Arc; use crate::error::APIError; use crate::ldk::{ - ChannelIdsMap, InboundPaymentInfoStorage, NetworkGraph, OutboundPaymentInfoStorage, - OutputSpenderTxes, SwapMap, + ChannelIdsMap, ClaimablePaymentStorage, InboundPaymentInfoStorage, InvoiceMetadataStorage, + NetworkGraph, OutboundPaymentInfoStorage, OutputSpenderTxes, SwapMap, }; use crate::utils::{parse_peer_info, LOGS_DIR}; @@ -23,6 +23,8 @@ pub(crate) const LDK_LOGS_FILE: &str = "logs.txt"; pub(crate) const INBOUND_PAYMENTS_FNAME: &str = "inbound_payments"; pub(crate) const OUTBOUND_PAYMENTS_FNAME: &str = "outbound_payments"; +pub(crate) const INVOICE_METADATA_FNAME: &str = "invoice_metadata"; +pub(crate) const CLAIMABLE_HTLCS_FNAME: &str = "claimable_htlcs"; pub(crate) const CHANNEL_PEER_DATA: &str = "channel_peer_data"; @@ -177,6 +179,28 @@ pub(crate) fn read_outbound_payment_info(path: &Path) -> OutboundPaymentInfoStor } } +pub(crate) fn read_invoice_metadata(path: &Path) -> InvoiceMetadataStorage { + if let Ok(file) = File::open(path) { + if let Ok(info) = InvoiceMetadataStorage::read(&mut BufReader::new(file)) { + return info; + } + } + InvoiceMetadataStorage { + invoices: HashMap::new(), + } +} + +pub(crate) fn read_claimable_htlcs(path: &Path) -> ClaimablePaymentStorage { + if let Ok(file) = File::open(path) { + if let Ok(info) = ClaimablePaymentStorage::read(&mut BufReader::new(file)) { + return info; + } + } + ClaimablePaymentStorage { + payments: HashMap::new(), + } +} + pub(crate) fn read_output_spender_txes(path: &Path) -> OutputSpenderTxes { if let Ok(file) = File::open(path) { if let Ok(info) = OutputSpenderTxes::read(&mut BufReader::new(file)) { diff --git a/src/error.rs b/src/error.rs index 52e04778..9967e800 100644 --- a/src/error.rs +++ b/src/error.rs @@ -210,6 +210,18 @@ pub enum APIError { #[error("Invalid transport endpoints: {0}")] InvalidTransportEndpoints(String), + #[error("Invoice is expired")] + InvoiceExpired, + + #[error("HTLC claim deadline exceeded")] + ClaimDeadlineExceeded, + + #[error("Invoice is not marked as HODL")] + InvoiceNotHodl, + + #[error("No claimable HTLC found for this invoice")] + InvoiceNotClaimable, + #[error("IO error: {0}")] IO(#[from] std::io::Error), @@ -234,6 +246,12 @@ pub enum APIError { #[error("Unable to find payment preimage, be sure you've provided the correct swap info")] MissingSwapPaymentPreimage, + #[error("Missing payment preimage")] + MissingPaymentPreimage, + + #[error("Invalid payment preimage")] + InvalidPaymentPreimage, + #[error("Network error: {0}")] Network(String), @@ -438,12 +456,14 @@ impl IntoResponse for APIError { | APIError::InvalidPassword(_) | APIError::InvalidPaymentHash(_) | APIError::InvalidPaymentSecret + | APIError::InvalidPaymentPreimage | APIError::InvalidPeerInfo(_) | APIError::InvalidPrecision(_) | APIError::InvalidPubkey | APIError::InvalidRecipientData(_) | APIError::InvalidRecipientID | APIError::InvalidRecipientNetwork + | APIError::InvoiceExpired | APIError::InvalidSwap(_) | APIError::InvalidSwapString(_, _) | APIError::InvalidTicker(_) @@ -452,8 +472,10 @@ impl IntoResponse for APIError { | APIError::InvalidTransportEndpoints(_) | APIError::MediaFileEmpty | APIError::MediaFileNotProvided + | APIError::MissingPaymentPreimage | APIError::MissingSwapPaymentPreimage | APIError::OutputBelowDustLimit + | APIError::ClaimDeadlineExceeded | APIError::UnsupportedBackupVersion { .. } => { (StatusCode::BAD_REQUEST, self.to_string(), self.name()) } @@ -499,6 +521,10 @@ impl IntoResponse for APIError { | APIError::UnsupportedTransportType => { (StatusCode::FORBIDDEN, self.to_string(), self.name()) } + | APIError::InvoiceNotHodl + | APIError::InvoiceNotClaimable => { + (StatusCode::FORBIDDEN, self.to_string(), self.name()) + } APIError::Network(_) | APIError::NoValidTransportEndpoint => ( StatusCode::SERVICE_UNAVAILABLE, self.to_string(), diff --git a/src/ldk.rs b/src/ldk.rs index b6e15e3f..5fe8e040 100644 --- a/src/ldk.rs +++ b/src/ldk.rs @@ -38,7 +38,7 @@ use lightning::util::persist::{ }; use lightning::util::ser::{ReadableArgs, Writeable}; use lightning::util::sweep as ldk_sweep; -use lightning::{chain, impl_writeable_tlv_based}; +use lightning::{chain, impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use lightning_background_processor::{process_events_async, GossipSync}; use lightning_block_sync::init; use lightning_block_sync::poll; @@ -84,8 +84,9 @@ use tokio::task::JoinHandle; use crate::bitcoind::BitcoindClient; use crate::disk::{ - self, FilesystemLogger, CHANNEL_IDS_FNAME, CHANNEL_PEER_DATA, INBOUND_PAYMENTS_FNAME, - MAKER_SWAPS_FNAME, OUTBOUND_PAYMENTS_FNAME, OUTPUT_SPENDER_TXES, TAKER_SWAPS_FNAME, + self, FilesystemLogger, CHANNEL_IDS_FNAME, CHANNEL_PEER_DATA, CLAIMABLE_HTLCS_FNAME, + INBOUND_PAYMENTS_FNAME, INVOICE_METADATA_FNAME, MAKER_SWAPS_FNAME, OUTBOUND_PAYMENTS_FNAME, + OUTPUT_SPENDER_TXES, TAKER_SWAPS_FNAME, }; use crate::error::APIError; use crate::rgb::{check_rgb_proxy_endpoint, get_rgb_channel_info_optional, RgbLibWalletWrapper}; @@ -130,6 +131,69 @@ impl_writeable_tlv_based!(PaymentInfo, { (12, payee_pubkey, required), }); +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum InvoiceMode { + AutoClaim, + Hodl, +} + +impl_writeable_tlv_based_enum!(InvoiceMode, + (0, AutoClaim) => {}, + (1, Hodl) => {}, +); + +/// Invoice-level metadata persisted by payment_hash. +/// Holds static expectations from invoice creation. +#[derive(Clone, Debug)] +pub(crate) struct InvoiceMetadata { + /// Invoice mode: AutoClaim or Hodl. + pub(crate) mode: InvoiceMode, + /// Expected amount from the invoice (msat). Used for under/over checks. + pub(crate) expected_amt_msat: Option, + /// Invoice expiry (seconds since epoch). + pub(crate) expiry: Option, + /// Optional preimage (rarely present; caller usually keeps it). + pub(crate) preimage: Option, + /// Optional external reference (swap/order id, etc.). + pub(crate) external_ref: Option, +} + +impl_writeable_tlv_based!(InvoiceMetadata, { + (0, mode, required), + (2, expected_amt_msat, required), + (4, expiry, required), + (6, preimage, option), + (8, external_ref, option), +}); + +/// Persisted HTLC claimable state for HODL invoices. +/// Stored when we receive `Event::PaymentClaimable` in HODL mode and used by +/// settle/cancel and the auto-expiry sweep. +#[derive(Clone, Debug)] +pub(crate) struct ClaimablePayment { + /// Payment hash for this inbound HTLC. + pub(crate) payment_hash: PaymentHash, + /// Optional preimage (may be absent; caller usually holds it). + pub(crate) payment_preimage: Option, + /// HTLC amount in millisatoshis (received amount). + pub(crate) amount_msat: u64, + /// Invoice expiry timestamp (seconds since epoch). + pub(crate) invoice_expiry: Option, + /// Optional absolute deadline as block height from PaymentClaimable. + pub(crate) claim_deadline_height: Option, + /// When we stored this claimable (seconds since epoch). + pub(crate) created_at: u64, +} + +impl_writeable_tlv_based!(ClaimablePayment, { + (0, payment_hash, required), + (2, payment_preimage, required), + (4, amount_msat, required), + (6, invoice_expiry, required), + (8, claim_deadline_height, required), + (10, created_at, required), +}); + pub(crate) struct InboundPaymentInfoStorage { pub(crate) payments: HashMap, } @@ -138,6 +202,14 @@ impl_writeable_tlv_based!(InboundPaymentInfoStorage, { (0, payments, required), }); +pub(crate) struct InvoiceMetadataStorage { + pub(crate) invoices: HashMap, +} + +impl_writeable_tlv_based!(InvoiceMetadataStorage, { + (0, invoices, required), +}); + pub(crate) struct OutboundPaymentInfoStorage { pub(crate) payments: HashMap, } @@ -146,6 +218,14 @@ impl_writeable_tlv_based!(OutboundPaymentInfoStorage, { (0, payments, required), }); +pub(crate) struct ClaimablePaymentStorage { + pub(crate) payments: HashMap, +} + +impl_writeable_tlv_based!(ClaimablePaymentStorage, { + (0, payments, required), +}); + pub(crate) struct SwapMap { pub(crate) swaps: HashMap, } @@ -163,6 +243,41 @@ impl_writeable_tlv_based!(ChannelIdsMap, { }); impl UnlockedAppState { + /// Remove and return claimables that are expired or past deadline. + pub(crate) fn expire_claimables(&self, now_ts: u64, current_height: u32) -> Vec { + let mut claimables = self.get_claimable_htlcs(); + let mut expired = vec![]; + let to_remove: Vec = claimables + .payments + .iter() + .filter_map(|(hash, c)| { + let deadline_passed = c + .claim_deadline_height + .map(|h| current_height >= h) + .unwrap_or(false); + let invoice_expired = c + .invoice_expiry + .map(|e| now_ts >= e) + .unwrap_or(false); + if deadline_passed || invoice_expired { + Some(*hash) + } else { + None + } + }) + .collect(); + + for hash in to_remove.iter() { + if let Some(c) = claimables.payments.remove(hash) { + expired.push(c); + } + } + if !to_remove.is_empty() { + self.save_claimable_htlcs(claimables); + } + expired + } + pub(crate) fn add_maker_swap(&self, payment_hash: PaymentHash, swap: SwapData) { let mut maker_swaps = self.get_maker_swaps(); maker_swaps.swaps.insert(payment_hash, swap); @@ -237,6 +352,36 @@ impl UnlockedAppState { self.save_inbound_payments(inbound); } + pub(crate) fn add_invoice_metadata(&self, payment_hash: PaymentHash, metadata: InvoiceMetadata) { + let mut invoices = self.get_invoice_metadata(); + invoices.invoices.insert(payment_hash, metadata); + self.save_invoice_metadata(invoices); + } + + pub(crate) fn invoice_metadata(&self) -> HashMap { + self.get_invoice_metadata().invoices.clone() + } + + pub(crate) fn upsert_claimable_payment(&self, claimable: ClaimablePayment) { + let mut claimables = self.get_claimable_htlcs(); + claimables + .payments + .insert(claimable.payment_hash, claimable); + self.save_claimable_htlcs(claimables); + } + + pub(crate) fn take_claimable_payment( + &self, + payment_hash: &PaymentHash, + ) -> Option { + let mut claimables = self.get_claimable_htlcs(); + let res = claimables.payments.remove(payment_hash); + if res.is_some() { + self.save_claimable_htlcs(claimables); + } + res + } + pub(crate) fn add_outbound_payment( &self, payment_id: PaymentId, @@ -288,13 +433,25 @@ impl UnlockedAppState { .unwrap(); } + fn save_invoice_metadata(&self, invoices: MutexGuard) { + self.fs_store + .write("", "", INVOICE_METADATA_FNAME, &invoices.encode()) + .unwrap(); + } + fn save_outbound_payments(&self, outbound: MutexGuard) { self.fs_store .write("", "", OUTBOUND_PAYMENTS_FNAME, &outbound.encode()) .unwrap(); } - fn upsert_inbound_payment( + fn save_claimable_htlcs(&self, claimables: MutexGuard) { + self.fs_store + .write("", "", CLAIMABLE_HTLCS_FNAME, &claimables.encode()) + .unwrap(); + } + + pub fn upsert_inbound_payment( &self, payment_hash: PaymentHash, status: HTLCStatus, @@ -652,7 +809,7 @@ async fn handle_ldk_events( receiver_node_id: _, via_channel_id: _, via_user_channel_id: _, - claim_deadline: _, + claim_deadline, onion_fields: _, counterparty_skimmed_fee_msat: _, } => { @@ -661,21 +818,118 @@ async fn handle_ldk_events( payment_hash, amount_msat, ); - let payment_preimage = match purpose { + + let (payment_preimage, payment_secret) = match purpose { PaymentPurpose::Bolt11InvoicePayment { - payment_preimage, .. - } => payment_preimage, + payment_preimage, + payment_secret, + .. + } => (payment_preimage, Some(payment_secret)), PaymentPurpose::Bolt12OfferPayment { - payment_preimage, .. - } => payment_preimage, + payment_preimage, + payment_secret, + .. + } => (payment_preimage, Some(payment_secret)), PaymentPurpose::Bolt12RefundPayment { - payment_preimage, .. - } => payment_preimage, - PaymentPurpose::SpontaneousPayment(preimage) => Some(preimage), + payment_preimage, + payment_secret, + .. + } => (payment_preimage, Some(payment_secret)), + PaymentPurpose::SpontaneousPayment(preimage) => (Some(preimage), None), }; - unlocked_state - .channel_manager - .claim_funds(payment_preimage.unwrap()); + + let invoice_metadata = unlocked_state + .invoice_metadata() + .get(&payment_hash) + .cloned(); + + let Some(metadata) = invoice_metadata else { + tracing::warn!("PaymentClaimable for unknown invoice (hash: {payment_hash:?}); failing backwards"); + unlocked_state.channel_manager.fail_htlc_backwards(&payment_hash); + return Ok(()); + }; + + let now_ts = get_current_timestamp(); + if let Some(expiry) = metadata.expiry { + if now_ts >= expiry { + tracing::warn!( + "Received HTLC for expired invoice {payment_hash:?} (expiry {expiry})" + ); + unlocked_state + .channel_manager + .fail_htlc_backwards(&payment_hash); + unlocked_state.upsert_inbound_payment( + payment_hash, + HTLCStatus::Failed, + payment_preimage, + payment_secret, + Some(amount_msat), + unlocked_state.channel_manager.get_our_node_id(), + ); + return Ok(()); + } + } + + if let Some(expected) = metadata.expected_amt_msat { + if amount_msat < expected { + tracing::warn!( + "Received {} msat for invoice {} but expected at least {} msat", + amount_msat, + payment_hash, + expected + ); + unlocked_state + .channel_manager + .fail_htlc_backwards(&payment_hash); + unlocked_state.upsert_inbound_payment( + payment_hash, + HTLCStatus::Failed, + payment_preimage, + payment_secret, + Some(amount_msat), + unlocked_state.channel_manager.get_our_node_id(), + ); + return Ok(()); + } + } + + match metadata.mode { + InvoiceMode::AutoClaim => { + let Some(preimage) = payment_preimage else { + tracing::error!( + "Missing payment preimage for standard invoice {:?}, cannot claim", + payment_hash + ); + unlocked_state + .channel_manager + .fail_htlc_backwards(&payment_hash); + return Ok(()); + }; + unlocked_state.channel_manager.claim_funds(preimage); + } + InvoiceMode::Hodl => { + let claim_deadline_height = claim_deadline.map(|h| h); + + let preimage = payment_preimage.or(metadata.preimage); + let claimable = ClaimablePayment { + payment_hash, + payment_preimage: preimage, + amount_msat, + invoice_expiry: metadata.expiry, + claim_deadline_height, + created_at: now_ts, + }; + unlocked_state.upsert_claimable_payment(claimable); + unlocked_state.upsert_inbound_payment( + payment_hash, + HTLCStatus::Pending, + preimage, + payment_secret, + Some(amount_msat), + unlocked_state.channel_manager.get_our_node_id(), + ); + } + } } Event::PaymentClaimed { payment_hash, @@ -1955,6 +2209,12 @@ pub(crate) async fn start_ldk( let inbound_payments = Arc::new(Mutex::new(disk::read_inbound_payment_info( &ldk_data_dir.join(INBOUND_PAYMENTS_FNAME), ))); + let invoice_metadata = Arc::new(Mutex::new(disk::read_invoice_metadata( + &ldk_data_dir.join(INVOICE_METADATA_FNAME), + ))); + let claimable_htlcs = Arc::new(Mutex::new(disk::read_claimable_htlcs( + &ldk_data_dir.join(CLAIMABLE_HTLCS_FNAME), + ))); let outbound_payments = Arc::new(Mutex::new(disk::read_outbound_payment_info( &ldk_data_dir.join(OUTBOUND_PAYMENTS_FNAME), ))); @@ -1985,6 +2245,8 @@ pub(crate) async fn start_ldk( let unlocked_state = Arc::new(UnlockedAppState { channel_manager: Arc::clone(&channel_manager), inbound_payments, + invoice_metadata, + claimable_htlcs, keys_manager, network_graph, chain_monitor: chain_monitor.clone(), @@ -2024,6 +2286,35 @@ pub(crate) async fn start_ldk( async move { handle_ldk_events(event, unlocked_state_copy, static_state_copy).await } }; + // Background task: monitor claimable HTLCs for expiry/deadline and fail them. + { + let unlocked_state_claimable = Arc::clone(&unlocked_state); + tokio::spawn(async move { + loop { + tokio::time::sleep(Duration::from_secs(30)).await; + let now = get_current_timestamp(); + let height = unlocked_state_claimable + .channel_manager + .current_best_block() + .height; + let expired = unlocked_state_claimable.expire_claimables(now, height); + for claimable in expired { + unlocked_state_claimable + .channel_manager + .fail_htlc_backwards(&claimable.payment_hash); + unlocked_state_claimable.upsert_inbound_payment( + claimable.payment_hash, + HTLCStatus::Failed, + claimable.payment_preimage, + None, + Some(claimable.amount_msat), + unlocked_state_claimable.channel_manager.get_our_node_id(), + ); + } + } + }); + } + // Background Processing let (bp_exit, bp_exit_check) = tokio::sync::watch::channel(()); let background_processor = tokio::spawn(process_events_async( diff --git a/src/main.rs b/src/main.rs index aaa98872..a4fefb6c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,7 +50,7 @@ use crate::routes::{ list_peers, list_swaps, list_transactions, list_transfers, list_unspents, ln_invoice, lock, maker_execute, maker_init, network_info, node_info, open_channel, post_asset_media, refresh_transfers, restore, revoke_token, rgb_invoice, send_asset, send_btc, - send_onion_message, send_payment, shutdown, sign_message, sync, taker, unlock, + send_onion_message, send_payment, shutdown, sign_message, sync, taker, unlock, invoice_hodl, invoice_settle, invoice_cancel }; use crate::utils::{start_daemon, AppState, LOGS_DIR}; @@ -139,6 +139,9 @@ pub(crate) async fn app(args: UserArgs) -> Result<(Router, Arc), AppEr .route("/listtransfers", post(list_transfers)) .route("/listunspents", post(list_unspents)) .route("/lninvoice", post(ln_invoice)) + .route("/invoice/hodl", post(invoice_hodl)) + .route("/invoice/settle", post(invoice_settle)) + .route("/invoice/cancel", post(invoice_cancel)) .route("/lock", post(lock)) .route("/makerexecute", post(maker_execute)) .route("/makerinit", post(maker_init)) diff --git a/src/routes.rs b/src/routes.rs index e65c196b..3c4d7620 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -13,7 +13,10 @@ use hex::DisplayHex; use lightning::ln::bolt11_payment::{ payment_parameters_from_invoice, payment_parameters_from_zero_amount_invoice, }; -use lightning::ln::invoice_utils::create_invoice_from_channelmanager; +use lightning::ln::invoice_utils::{ + create_invoice_from_channelmanager, + create_invoice_from_channelmanager_and_duration_since_epoch_with_payment_hash, +}; use lightning::ln::types::ChannelId; use lightning::offers::offer::{self, Offer}; use lightning::onion_message::messenger::Destination; @@ -69,7 +72,7 @@ use std::{ path::{Path, PathBuf}, str::FromStr, sync::Arc, - time::Duration, + time::{Duration, SystemTime}, }; use tokio::{ fs::File, @@ -91,7 +94,7 @@ use crate::{ use crate::{ disk::{self, CHANNEL_PEER_DATA}, error::APIError, - ldk::{PaymentInfo, FEE_RATE, UTXO_SIZE_SAT}, + ldk::{InvoiceMetadata, InvoiceMode, PaymentInfo, FEE_RATE, UTXO_SIZE_SAT}, utils::{ connect_peer_if_necessary, get_current_timestamp, no_cancel, parse_peer_info, AppState, }, @@ -576,6 +579,7 @@ pub(crate) struct GetSwapResponse { pub(crate) enum HTLCStatus { Pending, Succeeded, + Cancelled, Failed, } @@ -583,6 +587,7 @@ impl_writeable_tlv_based_enum!(HTLCStatus, (0, Pending) => {}, (1, Succeeded) => {}, (2, Failed) => {}, + (3, Cancelled) => {}, ); #[derive(Debug, Deserialize, Serialize)] @@ -628,6 +633,17 @@ pub(crate) struct InvoiceStatusResponse { pub(crate) status: InvoiceStatus, } +#[derive(Deserialize, Serialize)] +pub(crate) struct InvoiceSettleRequest { + pub(crate) invoice: String, + pub(crate) payment_preimage: Option, // Externally discovered pre-image that should be used to settle the hold invoice. +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct InvoiceCancelRequest { + pub(crate) payment_hash: String, +} + #[derive(Deserialize, Serialize)] pub(crate) struct IssueAssetCFARequest { pub(crate) amounts: Vec, @@ -761,6 +777,22 @@ pub(crate) struct LNInvoiceResponse { pub(crate) invoice: String, } +#[derive(Deserialize, Serialize)] +pub(crate) struct InvoiceHodlRequest { + pub(crate) amt_msat: Option, + pub(crate) expiry_sec: u32, + pub(crate) asset_id: Option, + pub(crate) asset_amount: Option, + pub(crate) payment_hash: String, + pub(crate) external_ref: Option, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct InvoiceHodlResponse { + pub(crate) invoice: String, + pub(crate) payment_secret: String, +} + #[derive(Deserialize, Serialize)] pub(crate) struct MakerExecuteRequest { pub(crate) swapstring: String, @@ -1760,6 +1792,7 @@ pub(crate) async fn invoice_status( HTLCStatus::Pending => InvoiceStatus::Pending, HTLCStatus::Succeeded => InvoiceStatus::Succeeded, HTLCStatus::Failed => InvoiceStatus::Failed, + HTLCStatus::Cancelled => InvoiceStatus::Failed, }, None => return Err(APIError::UnknownLNInvoice), }; @@ -2560,6 +2593,21 @@ pub(crate) async fn ln_invoice( }, ); + let expiry_ts = invoice + .duration_since_epoch() + .as_secs() + .saturating_add(invoice.expiry_time().as_secs()); + unlocked_state.add_invoice_metadata( + payment_hash, + InvoiceMetadata { + mode: InvoiceMode::AutoClaim, + expected_amt_msat: payload.amt_msat.or_else(|| invoice.amount_milli_satoshis()), + expiry: Some(expiry_ts), + preimage: None, + external_ref: None, + }, + ); + Ok(Json(LNInvoiceResponse { invoice: invoice.to_string(), })) @@ -2567,6 +2615,223 @@ pub(crate) async fn ln_invoice( .await } +pub(crate) async fn invoice_hodl( + State(state): State>, + WithRejection(Json(payload), _): WithRejection, APIError>, +) -> Result, APIError> { + no_cancel(async move { + let guard = state.check_unlocked().await?; + let unlocked_state = guard.as_ref().unwrap(); + + let contract_id = if let Some(asset_id) = payload.asset_id { + Some(ContractId::from_str(&asset_id).map_err(|_| APIError::InvalidAssetID(asset_id))?) + } else { + None + }; + + if contract_id.is_some() && payload.amt_msat.unwrap_or(0) < INVOICE_MIN_MSAT { + return Err(APIError::InvalidAmount(format!( + "amt_msat cannot be less than {INVOICE_MIN_MSAT} when transferring an RGB asset" + ))); + } + + let currency = match state.static_state.network { + RgbLibNetwork::Mainnet => Currency::Bitcoin, + RgbLibNetwork::Testnet | RgbLibNetwork::Testnet4 => Currency::BitcoinTestnet, + RgbLibNetwork::Regtest => Currency::Regtest, + RgbLibNetwork::Signet => Currency::Signet, + }; + + let payment_hash_str = payload.payment_hash.clone(); + if payment_hash_str.is_empty() { + return Err(APIError::InvalidPaymentHash("missing payment_hash".into())); + } + let hash_vec = hex_str_to_vec(&payment_hash_str) + .ok_or(APIError::InvalidPaymentHash(payment_hash_str.clone()))?; + if hash_vec.len() != 32 { + return Err(APIError::InvalidPaymentHash(payment_hash_str)); + } + let hash_bytes: [u8; 32] = hash_vec + .try_into() + .map_err(|_| APIError::InvalidPaymentHash(payment_hash_str.clone()))?; + let payment_hash = PaymentHash(hash_bytes); + + let duration_since_epoch = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_err(|_| APIError::FailedInvoiceCreation("system time before UNIX_EPOCH".into()))?; + + let invoice = create_invoice_from_channelmanager_and_duration_since_epoch_with_payment_hash( + &unlocked_state.channel_manager, + unlocked_state.keys_manager.clone(), + state.static_state.logger.clone(), + currency, + payload.amt_msat, + "ldk-tutorial-node".to_string(), + duration_since_epoch, + payload.expiry_sec, + payment_hash, + None, + contract_id, + payload.asset_amount, + ) + .map_err(|e| APIError::FailedInvoiceCreation(e.to_string()))?; + + let created_at = get_current_timestamp(); + unlocked_state.add_inbound_payment( + payment_hash, + PaymentInfo { + preimage: None, + secret: Some(*invoice.payment_secret()), + status: HTLCStatus::Pending, + amt_msat: payload.amt_msat, + created_at, + updated_at: created_at, + payee_pubkey: unlocked_state.channel_manager.get_our_node_id(), + }, + ); + + let expiry_ts = invoice + .duration_since_epoch() + .as_secs() + .saturating_add(invoice.expiry_time().as_secs()); + unlocked_state.add_invoice_metadata( + payment_hash, + InvoiceMetadata { + mode: InvoiceMode::Hodl, + expected_amt_msat: payload.amt_msat.or_else(|| invoice.amount_milli_satoshis()), + expiry: Some(expiry_ts), + preimage: None, + external_ref: payload.external_ref.clone(), + }, + ); + + Ok(Json(InvoiceHodlResponse { + invoice: invoice.to_string(), + payment_secret: hex_str(&invoice.payment_secret().0), + })) + }) + .await +} + +pub(crate) async fn invoice_settle( + State(state): State>, + WithRejection(Json(payload), _): WithRejection, APIError>, +) -> Result, APIError> { + no_cancel(async move { + let guard = state.check_unlocked().await?; + let unlocked_state = guard.as_ref().unwrap(); + + let invoice = Bolt11Invoice::from_str(&payload.invoice) + .map_err(|e| APIError::InvalidInvoice(e.to_string()))?; + let payment_hash = PaymentHash(invoice.payment_hash().to_byte_array()); + + let provided_preimage = if let Some(preimage_hex) = payload.payment_preimage.clone() { + let preimage_vec = hex_str_to_vec(&preimage_hex); + if preimage_vec.as_ref().map(|v| v.len()) != Some(32) { + return Err(APIError::InvalidPaymentPreimage); + } + Some(PaymentPreimage( + preimage_vec.unwrap().try_into().unwrap(), + )) + } else { + None + }; + + let metadata = unlocked_state + .invoice_metadata() + .get(&payment_hash) + .cloned() + .ok_or(APIError::UnknownLNInvoice)?; + + + if metadata.mode != InvoiceMode::Hodl { + return Err(APIError::InvoiceNotHodl); + } + + let claimable = unlocked_state + .take_claimable_payment(&payment_hash) + .ok_or(APIError::InvoiceNotClaimable)?; + + let current_height = unlocked_state + .channel_manager + .current_best_block() + .height; + if let Some(deadline_height) = claimable.claim_deadline_height { + if current_height >= deadline_height { + return Err(APIError::ClaimDeadlineExceeded); + } + } + + let now = get_current_timestamp(); + if let Some(expiry) = claimable.invoice_expiry { + if now >= expiry { + return Err(APIError::InvoiceExpired); + } + } + + let preimage = provided_preimage + .or(claimable.payment_preimage) + .or(metadata.preimage) + .ok_or(APIError::MissingPaymentPreimage)?; + let computed_hash = PaymentHash(Sha256::hash(&preimage.0).to_byte_array()); + if computed_hash != payment_hash { + return Err(APIError::InvalidPaymentHash(hex_str(&payment_hash.0))); + } + unlocked_state.channel_manager.claim_funds(preimage); + + Ok(Json(EmptyResponse {})) + }) + .await +} + +pub(crate) async fn invoice_cancel( + State(state): State>, + WithRejection(Json(payload), _): WithRejection, APIError>, +) -> Result, APIError> { + no_cancel(async move { + let guard = state.check_unlocked().await?; + let unlocked_state = guard.as_ref().unwrap(); + + let hash_vec = hex_str_to_vec(&payload.payment_hash) + .ok_or_else(|| APIError::InvalidPaymentHash(payload.payment_hash.clone()))?; + if hash_vec.len() != 32 { + return Err(APIError::InvalidPaymentHash(payload.payment_hash.clone())); + } + let payment_hash = + PaymentHash(hash_vec.try_into().map_err(|_| APIError::InvalidPaymentHash(payload.payment_hash.clone()))?); + + let metadata = unlocked_state + .invoice_metadata() + .get(&payment_hash) + .cloned() + .ok_or(APIError::UnknownLNInvoice)?; + + if metadata.mode != InvoiceMode::Hodl { + return Err(APIError::InvoiceNotHodl); + } + + let claimable = unlocked_state + .take_claimable_payment(&payment_hash) + .ok_or(APIError::InvoiceNotClaimable)?; + + unlocked_state + .channel_manager + .fail_htlc_backwards(&payment_hash); + + unlocked_state.upsert_inbound_payment( + payment_hash, + HTLCStatus::Cancelled, + None, + None, + Some(claimable.amount_msat), + unlocked_state.channel_manager.get_our_node_id(), + ); + + Ok(Json(EmptyResponse {})) + }) + .await +} + pub(crate) async fn lock( State(state): State>, ) -> Result, APIError> { diff --git a/src/utils.rs b/src/utils.rs index c214f086..ac5421dd 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -38,9 +38,9 @@ use crate::{ disk::FilesystemLogger, error::{APIError, AppError}, ldk::{ - BumpTxEventHandler, ChainMonitor, ChannelManager, InboundPaymentInfoStorage, - LdkBackgroundServices, NetworkGraph, OnionMessenger, OutboundPaymentInfoStorage, - OutputSweeper, PeerManager, SwapMap, + BumpTxEventHandler, ChainMonitor, ChannelManager, ClaimablePaymentStorage, + InboundPaymentInfoStorage, InvoiceMetadataStorage, LdkBackgroundServices, NetworkGraph, + OnionMessenger, OutboundPaymentInfoStorage, OutputSweeper, PeerManager, SwapMap, }, }; @@ -95,6 +95,8 @@ pub(crate) struct StaticState { pub(crate) struct UnlockedAppState { pub(crate) channel_manager: Arc, pub(crate) inbound_payments: Arc>, + pub(crate) invoice_metadata: Arc>, + pub(crate) claimable_htlcs: Arc>, pub(crate) keys_manager: Arc, pub(crate) network_graph: Arc, pub(crate) chain_monitor: Arc, @@ -118,6 +120,14 @@ impl UnlockedAppState { self.inbound_payments.lock().unwrap() } + pub(crate) fn get_invoice_metadata(&self) -> MutexGuard<'_, InvoiceMetadataStorage> { + self.invoice_metadata.lock().unwrap() + } + + pub(crate) fn get_claimable_htlcs(&self) -> MutexGuard<'_, ClaimablePaymentStorage> { + self.claimable_htlcs.lock().unwrap() + } + pub(crate) fn get_outbound_payments(&self) -> MutexGuard<'_, OutboundPaymentInfoStorage> { self.outbound_payments.lock().unwrap() } From 0e039ffbaa73fa90c9bf2fb5d0cd04e5aacf2adc Mon Sep 17 00:00:00 2001 From: Xalkan Duarte Date: Mon, 22 Dec 2025 10:25:26 -0300 Subject: [PATCH 02/17] review hodl invoices --- openapi.yaml | 10 ++- src/error.rs | 4 -- src/ldk.rs | 188 +++++++++++++++++++++++++++++++++++++++----------- src/routes.rs | 66 ++++-------------- src/utils.rs | 46 ++++++++++++ 5 files changed, 216 insertions(+), 98 deletions(-) diff --git a/openapi.yaml b/openapi.yaml index 676e387f..7ce1cc2f 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1848,6 +1848,7 @@ components: type: object required: - payment_hash + - expiry_sec properties: amt_msat: type: integer @@ -1878,15 +1879,20 @@ components: example: 777a7756c620868199ed5fdc35bee4095b5709d543e5c2bf0494396bf27d2ea2 InvoiceSettleRequest: type: object + required: + - payment_hash + - payment_preimage properties: - invoice: + payment_hash: type: string - example: lnbcrt30u1pjv6yzndqud3jxktt5w46x7unfv9kz6mn0v3jsnp4qdpc280eur52luxppv6f3nnj8l6vnd9g2hnv3qv6mjhmhvlzf6327pp5tjjasx6g9dqptea3fhm6yllq5wxzycnnvp8l6wcq3d6j2uvpryuqsp5l8az8x3g8fe05dg7cmgddld3da09nfjvky8xftwsk4cj8p2l7kfq9qyysgqcqpcxqzdylzlwfnkyw3jv344x4rzwgkk53ng0fhxy5rdduk4g5tpvea8xa6rfckkza35va28xjn2tqkhgarcxep5umm4x5k56wfcdvu95eq7qzp20vrl4xz76syapsa3c09j7lg5gerkaj63llj0ark7ph8hfketn6fkqzm8laf66dhsncm23wkwm5l5377we9e8lnlknnkwje5eefkccusqm6rqt8 + example: 3febfae1e68b190c15461f4c2a3290f9af1dae63fd7d620d2bd61601869026cd payment_preimage: type: string example: 777a7756c620868199ed5fdc35bee4095b5709d543e5c2bf0494396bf27d2ea2 InvoiceCancelRequest: type: object + required: + - payment_hash properties: payment_hash: type: string diff --git a/src/error.rs b/src/error.rs index 9967e800..f2fe35d2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -246,9 +246,6 @@ pub enum APIError { #[error("Unable to find payment preimage, be sure you've provided the correct swap info")] MissingSwapPaymentPreimage, - #[error("Missing payment preimage")] - MissingPaymentPreimage, - #[error("Invalid payment preimage")] InvalidPaymentPreimage, @@ -472,7 +469,6 @@ impl IntoResponse for APIError { | APIError::InvalidTransportEndpoints(_) | APIError::MediaFileEmpty | APIError::MediaFileNotProvided - | APIError::MissingPaymentPreimage | APIError::MissingSwapPaymentPreimage | APIError::OutputBelowDustLimit | APIError::ClaimDeadlineExceeded diff --git a/src/ldk.rs b/src/ldk.rs index 5fe8e040..a16d8bb3 100644 --- a/src/ldk.rs +++ b/src/ldk.rs @@ -108,6 +108,7 @@ pub(crate) struct LdkBackgroundServices { peer_manager: Arc, bp_exit: Sender<()>, background_processor: Option>>, + claimable_expiry_task: Option>, } #[derive(Clone, Debug)] @@ -152,8 +153,6 @@ pub(crate) struct InvoiceMetadata { pub(crate) expected_amt_msat: Option, /// Invoice expiry (seconds since epoch). pub(crate) expiry: Option, - /// Optional preimage (rarely present; caller usually keeps it). - pub(crate) preimage: Option, /// Optional external reference (swap/order id, etc.). pub(crate) external_ref: Option, } @@ -162,8 +161,7 @@ impl_writeable_tlv_based!(InvoiceMetadata, { (0, mode, required), (2, expected_amt_msat, required), (4, expiry, required), - (6, preimage, option), - (8, external_ref, option), + (6, external_ref, option), }); /// Persisted HTLC claimable state for HODL invoices. @@ -173,8 +171,6 @@ impl_writeable_tlv_based!(InvoiceMetadata, { pub(crate) struct ClaimablePayment { /// Payment hash for this inbound HTLC. pub(crate) payment_hash: PaymentHash, - /// Optional preimage (may be absent; caller usually holds it). - pub(crate) payment_preimage: Option, /// HTLC amount in millisatoshis (received amount). pub(crate) amount_msat: u64, /// Invoice expiry timestamp (seconds since epoch). @@ -187,11 +183,10 @@ pub(crate) struct ClaimablePayment { impl_writeable_tlv_based!(ClaimablePayment, { (0, payment_hash, required), - (2, payment_preimage, required), - (4, amount_msat, required), - (6, invoice_expiry, required), - (8, claim_deadline_height, required), - (10, created_at, required), + (2, amount_msat, required), + (4, invoice_expiry, required), + (6, claim_deadline_height, required), + (8, created_at, required), }); pub(crate) struct InboundPaymentInfoStorage { @@ -382,6 +377,10 @@ impl UnlockedAppState { res } + pub(crate) fn claimable_payment(&self, payment_hash: &PaymentHash) -> Option { + self.get_claimable_htlcs().payments.get(payment_hash).cloned() + } + pub(crate) fn add_outbound_payment( &self, payment_id: PaymentId, @@ -838,18 +837,98 @@ async fn handle_ldk_events( PaymentPurpose::SpontaneousPayment(preimage) => (Some(preimage), None), }; + // Invoice metadata is optional - if missing, default to auto-claim behavior let invoice_metadata = unlocked_state .invoice_metadata() .get(&payment_hash) .cloned(); + // If no metadata exists, check if this is a legacy invoice in inbound_payments let Some(metadata) = invoice_metadata else { - tracing::warn!("PaymentClaimable for unknown invoice (hash: {payment_hash:?}); failing backwards"); - unlocked_state.channel_manager.fail_htlc_backwards(&payment_hash); + // Check if this payment_hash exists in inbound_payments (legacy invoice) + let inbound_payments = unlocked_state.inbound_payments(); + let legacy_invoice = inbound_payments.get(&payment_hash); + + if let Some(legacy_payment_info) = legacy_invoice { + // This is a legacy invoice (created before invoice_metadata feature) + // Treat it as auto-claim invoice with basic validation + tracing::info!( + "Legacy invoice detected (no metadata) for payment {:?}, treating as auto-claim", + payment_hash + ); + + // For legacy invoices, LDK should provide the preimage for standard Bolt11 invoices + let Some(preimage) = payment_preimage else { + // If preimage is missing, check if it was stored in inbound_payment + // (unlikely for standard invoices, but possible for edge cases) + if let Some(stored_preimage) = legacy_payment_info.preimage { + tracing::info!( + "Using stored preimage from inbound_payment for legacy invoice {:?}", + payment_hash + ); + unlocked_state.channel_manager.claim_funds(stored_preimage); + return Ok(()); + } + + tracing::error!( + "Missing payment preimage for legacy invoice {:?}, cannot claim. \ + This may indicate a corrupted state or LDK version issue.", + payment_hash + ); + unlocked_state + .channel_manager + .fail_htlc_backwards(&payment_hash); + return Ok(()); + }; + + // Basic validation: check amount if specified in legacy invoice + if let Some(expected_amt) = legacy_payment_info.amt_msat { + if amount_msat < expected_amt { + tracing::warn!( + "Received {} msat for legacy invoice {} but expected at least {} msat", + amount_msat, + payment_hash, + expected_amt + ); + unlocked_state + .channel_manager + .fail_htlc_backwards(&payment_hash); + unlocked_state.upsert_inbound_payment( + payment_hash, + HTLCStatus::Failed, + payment_preimage, + payment_secret, + Some(amount_msat), + unlocked_state.channel_manager.get_our_node_id(), + ); + return Ok(()); + } + } + + // Auto-claim legacy invoice + tracing::info!("Auto-claiming legacy invoice {:?}", payment_hash); + unlocked_state.channel_manager.claim_funds(preimage); + return Ok(()); + } + + // No metadata and not in inbound_payments - likely spontaneous/keysend payment + let Some(preimage) = payment_preimage else { + tracing::error!( + "Missing payment preimage for payment {:?}, cannot claim", + payment_hash + ); + unlocked_state + .channel_manager + .fail_htlc_backwards(&payment_hash); + return Ok(()); + }; + tracing::info!("Auto-claiming payment without metadata {:?}", payment_hash); + unlocked_state.channel_manager.claim_funds(preimage); return Ok(()); }; let now_ts = get_current_timestamp(); + // Metadata exists - apply expiry and amount checks if let Some(expiry) = metadata.expiry { if now_ts >= expiry { tracing::warn!( @@ -910,10 +989,8 @@ async fn handle_ldk_events( InvoiceMode::Hodl => { let claim_deadline_height = claim_deadline.map(|h| h); - let preimage = payment_preimage.or(metadata.preimage); let claimable = ClaimablePayment { payment_hash, - payment_preimage: preimage, amount_msat, invoice_expiry: metadata.expiry, claim_deadline_height, @@ -923,7 +1000,7 @@ async fn handle_ldk_events( unlocked_state.upsert_inbound_payment( payment_hash, HTLCStatus::Pending, - preimage, + None, payment_secret, Some(amount_msat), unlocked_state.channel_manager.get_our_node_id(), @@ -2287,33 +2364,55 @@ pub(crate) async fn start_ldk( }; // Background task: monitor claimable HTLCs for expiry/deadline and fail them. - { - let unlocked_state_claimable = Arc::clone(&unlocked_state); - tokio::spawn(async move { - loop { - tokio::time::sleep(Duration::from_secs(30)).await; - let now = get_current_timestamp(); - let height = unlocked_state_claimable + let stop_claimable_expiry = Arc::clone(&stop_processing); + let unlocked_state_claimable = Arc::clone(&unlocked_state); + let claimable_expiry_task = tokio::spawn(async move { + loop { + if stop_claimable_expiry.load(Ordering::Acquire) { + return; + } + tokio::time::sleep(Duration::from_secs(30)).await; + if stop_claimable_expiry.load(Ordering::Acquire) { + return; + } + let now = get_current_timestamp(); + let height = unlocked_state_claimable + .channel_manager + .current_best_block() + .height; + let expired = unlocked_state_claimable.expire_claimables(now, height); + for claimable in expired { + // expire_claimables() already removed it atomically, so we own it now + // Note: There's a potential race where user might call invoice_settle()/invoice_cancel() + // at the same time, but the mutex in get_claimable_htlcs() protects against this. + // If user already took it via take_claimable_payment(), expire_claimables() won't + // return it, so this is safe. + + tracing::info!( + "Expiring claimable payment {:?} (deadline: {:?}, expiry: {:?})", + claimable.payment_hash, + claimable.claim_deadline_height, + claimable.invoice_expiry + ); + + // Fail the HTLC backwards - this may be a no-op if already claimed/failed, + // but LDK should handle that gracefully + unlocked_state_claimable .channel_manager - .current_best_block() - .height; - let expired = unlocked_state_claimable.expire_claimables(now, height); - for claimable in expired { - unlocked_state_claimable - .channel_manager - .fail_htlc_backwards(&claimable.payment_hash); - unlocked_state_claimable.upsert_inbound_payment( - claimable.payment_hash, - HTLCStatus::Failed, - claimable.payment_preimage, - None, - Some(claimable.amount_msat), - unlocked_state_claimable.channel_manager.get_our_node_id(), - ); - } + .fail_htlc_backwards(&claimable.payment_hash); + + // Update payment status to Failed + unlocked_state_claimable.upsert_inbound_payment( + claimable.payment_hash, + HTLCStatus::Failed, + None, + None, + Some(claimable.amount_msat), + unlocked_state_claimable.channel_manager.get_our_node_id(), + ); } - }); - } + } + }); // Background Processing let (bp_exit, bp_exit_check) = tokio::sync::watch::channel(()); @@ -2448,6 +2547,7 @@ pub(crate) async fn start_ldk( peer_manager: peer_manager.clone(), bp_exit, background_processor: Some(background_processor), + claimable_expiry_task: Some(claimable_expiry_task), }, unlocked_state, )) @@ -2472,6 +2572,12 @@ impl AppState { .store(true, Ordering::Release); ldk_background_services.peer_manager.disconnect_all_peers(); + // Stop the claimable expiry task - abort it for immediate shutdown + // (it would exit gracefully via stop_processing flag, but aborting ensures immediate stop) + if let Some(claimable_task) = ldk_background_services.claimable_expiry_task.take() { + claimable_task.abort(); + } + // Stop the background processor. if !ldk_background_services.bp_exit.is_closed() { ldk_background_services.bp_exit.send(()).unwrap(); diff --git a/src/routes.rs b/src/routes.rs index 3c4d7620..a16b30df 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -85,7 +85,8 @@ use crate::swap::{SwapData, SwapInfo, SwapString}; use crate::utils::{ check_already_initialized, check_channel_id, check_password_strength, check_password_validity, encrypt_and_save_mnemonic, get_max_local_rgb_amount, get_mnemonic_path, get_route, hex_str, - hex_str_to_compressed_pubkey, hex_str_to_vec, UnlockedAppState, UserOnionMessageContents, + hex_str_to_compressed_pubkey, hex_str_to_vec, validate_and_parse_payment_hash, + validate_and_parse_payment_preimage, UnlockedAppState, UserOnionMessageContents, }; use crate::{ backup::{do_backup, restore_backup}, @@ -635,8 +636,8 @@ pub(crate) struct InvoiceStatusResponse { #[derive(Deserialize, Serialize)] pub(crate) struct InvoiceSettleRequest { - pub(crate) invoice: String, - pub(crate) payment_preimage: Option, // Externally discovered pre-image that should be used to settle the hold invoice. + pub(crate) payment_hash: String, + pub(crate) payment_preimage: String, } #[derive(Deserialize, Serialize)] @@ -2603,7 +2604,6 @@ pub(crate) async fn ln_invoice( mode: InvoiceMode::AutoClaim, expected_amt_msat: payload.amt_msat.or_else(|| invoice.amount_milli_satoshis()), expiry: Some(expiry_ts), - preimage: None, external_ref: None, }, ); @@ -2642,19 +2642,7 @@ pub(crate) async fn invoice_hodl( RgbLibNetwork::Signet => Currency::Signet, }; - let payment_hash_str = payload.payment_hash.clone(); - if payment_hash_str.is_empty() { - return Err(APIError::InvalidPaymentHash("missing payment_hash".into())); - } - let hash_vec = hex_str_to_vec(&payment_hash_str) - .ok_or(APIError::InvalidPaymentHash(payment_hash_str.clone()))?; - if hash_vec.len() != 32 { - return Err(APIError::InvalidPaymentHash(payment_hash_str)); - } - let hash_bytes: [u8; 32] = hash_vec - .try_into() - .map_err(|_| APIError::InvalidPaymentHash(payment_hash_str.clone()))?; - let payment_hash = PaymentHash(hash_bytes); + let payment_hash = validate_and_parse_payment_hash(&payload.payment_hash)?; let duration_since_epoch = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) @@ -2700,7 +2688,6 @@ pub(crate) async fn invoice_hodl( mode: InvoiceMode::Hodl, expected_amt_msat: payload.amt_msat.or_else(|| invoice.amount_milli_satoshis()), expiry: Some(expiry_ts), - preimage: None, external_ref: payload.external_ref.clone(), }, ); @@ -2721,21 +2708,8 @@ pub(crate) async fn invoice_settle( let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); - let invoice = Bolt11Invoice::from_str(&payload.invoice) - .map_err(|e| APIError::InvalidInvoice(e.to_string()))?; - let payment_hash = PaymentHash(invoice.payment_hash().to_byte_array()); - - let provided_preimage = if let Some(preimage_hex) = payload.payment_preimage.clone() { - let preimage_vec = hex_str_to_vec(&preimage_hex); - if preimage_vec.as_ref().map(|v| v.len()) != Some(32) { - return Err(APIError::InvalidPaymentPreimage); - } - Some(PaymentPreimage( - preimage_vec.unwrap().try_into().unwrap(), - )) - } else { - None - }; + let payment_hash = validate_and_parse_payment_hash(&payload.payment_hash)?; + let preimage = validate_and_parse_payment_preimage(&payload.payment_preimage, &payment_hash)?; let metadata = unlocked_state .invoice_metadata() @@ -2743,13 +2717,12 @@ pub(crate) async fn invoice_settle( .cloned() .ok_or(APIError::UnknownLNInvoice)?; - if metadata.mode != InvoiceMode::Hodl { return Err(APIError::InvoiceNotHodl); } let claimable = unlocked_state - .take_claimable_payment(&payment_hash) + .claimable_payment(&payment_hash) .ok_or(APIError::InvoiceNotClaimable)?; let current_height = unlocked_state @@ -2763,20 +2736,17 @@ pub(crate) async fn invoice_settle( } let now = get_current_timestamp(); - if let Some(expiry) = claimable.invoice_expiry { + if let Some(expiry) = metadata.expiry { if now >= expiry { return Err(APIError::InvoiceExpired); } } - let preimage = provided_preimage - .or(claimable.payment_preimage) - .or(metadata.preimage) - .ok_or(APIError::MissingPaymentPreimage)?; - let computed_hash = PaymentHash(Sha256::hash(&preimage.0).to_byte_array()); - if computed_hash != payment_hash { - return Err(APIError::InvalidPaymentHash(hex_str(&payment_hash.0))); - } + // All validations passed; now remove the claimable to avoid double-settlement. + let _ = unlocked_state + .take_claimable_payment(&payment_hash) + .ok_or(APIError::InvoiceNotClaimable)?; + unlocked_state.channel_manager.claim_funds(preimage); Ok(Json(EmptyResponse {})) @@ -2792,13 +2762,7 @@ pub(crate) async fn invoice_cancel( let guard = state.check_unlocked().await?; let unlocked_state = guard.as_ref().unwrap(); - let hash_vec = hex_str_to_vec(&payload.payment_hash) - .ok_or_else(|| APIError::InvalidPaymentHash(payload.payment_hash.clone()))?; - if hash_vec.len() != 32 { - return Err(APIError::InvalidPaymentHash(payload.payment_hash.clone())); - } - let payment_hash = - PaymentHash(hash_vec.try_into().map_err(|_| APIError::InvalidPaymentHash(payload.payment_hash.clone()))?); + let payment_hash = validate_and_parse_payment_hash(&payload.payment_hash)?; let metadata = unlocked_state .invoice_metadata() diff --git a/src/utils.rs b/src/utils.rs index ac5421dd..878eb380 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -8,7 +8,10 @@ use lightning::routing::router::{ Payee, PaymentParameters, Route, RouteHint, RouteParameters, Router as _, DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA, MAX_PATH_LENGTH_ESTIMATE, }; +use bitcoin::hashes::sha256::Hash as Sha256; +use bitcoin::hashes::Hash; use lightning::{ + ln::{PaymentHash, PaymentPreimage}, onion_message::packet::OnionMessageContents, sign::KeysManager, util::ser::{Writeable, Writer}, @@ -455,3 +458,46 @@ pub(crate) fn get_route( route.ok() } + +/// Validates a hex-encoded payment hash string and converts it to a PaymentHash. +/// Returns an error if the string is invalid, empty, or not exactly 32 bytes. +pub(crate) fn validate_and_parse_payment_hash( + payment_hash_str: &str, +) -> Result { + if payment_hash_str.is_empty() { + return Err(APIError::InvalidPaymentHash("missing payment_hash".into())); + } + let hash_vec = hex_str_to_vec(payment_hash_str) + .ok_or_else(|| APIError::InvalidPaymentHash(payment_hash_str.to_string()))?; + if hash_vec.len() != 32 { + return Err(APIError::InvalidPaymentHash(payment_hash_str.to_string())); + } + let hash_bytes: [u8; 32] = hash_vec + .try_into() + .map_err(|_| APIError::InvalidPaymentHash(payment_hash_str.to_string()))?; + Ok(PaymentHash(hash_bytes)) +} + +/// Validates a hex-encoded payment preimage string, converts it to a PaymentPreimage, +/// and verifies that it matches the provided payment hash. +/// Returns an error if the string is invalid, not exactly 32 bytes, or doesn't match the hash. +pub(crate) fn validate_and_parse_payment_preimage( + payment_preimage_str: &str, + payment_hash: &PaymentHash, +) -> Result { + let preimage_vec = hex_str_to_vec(payment_preimage_str) + .ok_or_else(|| APIError::InvalidPaymentPreimage)?; + if preimage_vec.len() != 32 { + return Err(APIError::InvalidPaymentPreimage); + } + let preimage = PaymentPreimage( + preimage_vec + .try_into() + .map_err(|_| APIError::InvalidPaymentPreimage)?, + ); + let computed_hash = PaymentHash(Sha256::hash(&preimage.0).to_byte_array()); + if computed_hash != *payment_hash { + return Err(APIError::InvalidPaymentPreimage); + } + Ok(preimage) +} From 2e7b3b0069b411d18d7eb93af900c4ed80799666 Mon Sep 17 00:00:00 2001 From: Boris Date: Tue, 30 Dec 2025 04:01:53 -0300 Subject: [PATCH 03/17] do not lose the ability to settle/cancel on error Reordered settle/cancel to avoid deleting claimables before we know the LDK action succeeded. In invoice_settle, call claim_funds first and return an error if LDK rejects it; only then best-effort remove the claimable entry. In invoice_cancel, fetch the claimable without removing it, attempt fail_htlc_backwards, return an error if that fails, and only then clean up the claimable. This prevents losing the ability to retry/cancel when LDK rejects the call. --- src/routes.rs | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/routes.rs b/src/routes.rs index a16b30df..5f05e00a 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -2743,11 +2743,14 @@ pub(crate) async fn invoice_settle( } // All validations passed; now remove the claimable to avoid double-settlement. - let _ = unlocked_state - .take_claimable_payment(&payment_hash) - .ok_or(APIError::InvoiceNotClaimable)?; - - unlocked_state.channel_manager.claim_funds(preimage); + let claimed = unlocked_state.channel_manager.claim_funds(preimage); + if !claimed { + // If LDK rejected the claim (e.g. HTLC already timed out/unknown), keep the claimable + // entry so the caller can retry or cancel. + return Err(APIError::InvoiceNotClaimable); + } + // Best-effort cleanup; ignore if another task removed it concurrently. + let _ = unlocked_state.take_claimable_payment(&payment_hash); Ok(Json(EmptyResponse {})) }) @@ -2775,12 +2778,18 @@ pub(crate) async fn invoice_cancel( } let claimable = unlocked_state - .take_claimable_payment(&payment_hash) + .claimable_payment(&payment_hash) .ok_or(APIError::InvoiceNotClaimable)?; - unlocked_state + let ok = unlocked_state .channel_manager .fail_htlc_backwards(&payment_hash); + if !ok { + // HTLC might already be gone; keep claimable so it can be retried/settled. + return Err(APIError::InvoiceNotClaimable); + } + // Best-effort cleanup after a successful fail; ignore if already removed. + let _ = unlocked_state.take_claimable_payment(&payment_hash); unlocked_state.upsert_inbound_payment( payment_hash, From f5dfec979bd8fec69870b74d7a4a3e541c2eb64c Mon Sep 17 00:00:00 2001 From: Boris Date: Tue, 30 Dec 2025 13:50:31 -0300 Subject: [PATCH 04/17] prevent adding a hodl invoice with duplicate hash --- src/error.rs | 4 ++++ src/routes.rs | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/error.rs b/src/error.rs index f2fe35d2..0ab92f8e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -162,6 +162,9 @@ pub enum APIError { #[error("Invalid payment hash: {0}")] InvalidPaymentHash(String), + #[error("Payment hash already used")] + PaymentHashAlreadyUsed, + #[error("Invalid payment secret")] InvalidPaymentSecret, @@ -452,6 +455,7 @@ impl IntoResponse for APIError { | APIError::InvalidOnionData(_) | APIError::InvalidPassword(_) | APIError::InvalidPaymentHash(_) + | APIError::PaymentHashAlreadyUsed | APIError::InvalidPaymentSecret | APIError::InvalidPaymentPreimage | APIError::InvalidPeerInfo(_) diff --git a/src/routes.rs b/src/routes.rs index 5f05e00a..9362cb71 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -2644,6 +2644,18 @@ pub(crate) async fn invoice_hodl( let payment_hash = validate_and_parse_payment_hash(&payload.payment_hash)?; + // Reject reusing a payment hash that already exists in any of the known stores. + let hash_already_used = unlocked_state + .invoice_metadata() + .contains_key(&payment_hash) + || unlocked_state + .inbound_payments() + .contains_key(&payment_hash) + || unlocked_state.claimable_payment(&payment_hash).is_some(); + if hash_already_used { + return Err(APIError::PaymentHashAlreadyUsed); + } + let duration_since_epoch = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .map_err(|_| APIError::FailedInvoiceCreation("system time before UNIX_EPOCH".into()))?; From a390e67396fc59796abc88d385fc729d7f867bb3 Mon Sep 17 00:00:00 2001 From: Boris Date: Tue, 30 Dec 2025 14:02:33 -0300 Subject: [PATCH 05/17] test: add integration tests for HODL invoices --- src/test/hodl_invoice.rs | 110 +++++++++++++++++++++++++++++++++++++++ src/test/mod.rs | 55 +++++++++++++++++++- 2 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 src/test/hodl_invoice.rs diff --git a/src/test/hodl_invoice.rs b/src/test/hodl_invoice.rs new file mode 100644 index 00000000..f91c1f15 --- /dev/null +++ b/src/test/hodl_invoice.rs @@ -0,0 +1,110 @@ +use bitcoin::hashes::{sha256::Hash as Sha256, Hash}; +use rand::RngCore; + +use super::*; + +const TEST_DIR_BASE: &str = "tmp/hodl_invoice/"; + +fn random_preimage_and_hash() -> (String, String) { + let mut preimage = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut preimage); + let preimage_hex = hex::encode(preimage); + let payment_hash = hex::encode(Sha256::hash(&preimage).to_byte_array()); + (preimage_hex, payment_hash) +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn settle_hodl_invoice() { + initialize(); + + let test_dir_base = format!("{TEST_DIR_BASE}settle/"); + let test_dir_node1 = format!("{test_dir_base}node1"); + let test_dir_node2 = format!("{test_dir_base}node2"); + let (node1_addr, _) = start_node(&test_dir_node1, NODE1_PEER_PORT, false).await; + let (node2_addr, _) = start_node(&test_dir_node2, NODE2_PEER_PORT, false).await; + + fund_and_create_utxos(node1_addr, None).await; + fund_and_create_utxos(node2_addr, None).await; + + let node2_pubkey = node_info(node2_addr).await.pubkey; + let _channel = open_channel( + node1_addr, + &node2_pubkey, + Some(NODE2_PEER_PORT), + Some(500000), + Some(0), + None, + None, + ) + .await; + + let (preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let InvoiceHodlResponse { invoice, .. } = + invoice_hodl(node2_addr, Some(50_000), 900, payment_hash_hex.clone()).await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + assert_eq!(decoded.payment_hash, payment_hash_hex); + + // Payer sees the payment pending until settle is called. + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + assert!(matches!(invoice_status(node2_addr, &invoice).await, InvoiceStatus::Pending)); + + // Settle with the chosen preimage. + invoice_settle(node2_addr, payment_hash_hex.clone(), preimage_hex.clone()).await; + + let payer_payment = + wait_for_ln_payment(node1_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert_eq!(payer_payment.status, HTLCStatus::Succeeded); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert_eq!(payee_payment.status, HTLCStatus::Succeeded); + assert!(matches!(invoice_status(node2_addr, &invoice).await, InvoiceStatus::Succeeded)); +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn cancel_hodl_invoice() { + initialize(); + + let test_dir_base = format!("{TEST_DIR_BASE}cancel/"); + let test_dir_node1 = format!("{test_dir_base}node1"); + let test_dir_node2 = format!("{test_dir_base}node2"); + let (node1_addr, _) = start_node(&test_dir_node1, NODE1_PEER_PORT + 10, false).await; + let (node2_addr, _) = start_node(&test_dir_node2, NODE2_PEER_PORT + 10, false).await; + + fund_and_create_utxos(node1_addr, None).await; + fund_and_create_utxos(node2_addr, None).await; + + let node2_pubkey = node_info(node2_addr).await.pubkey; + let _channel = open_channel( + node1_addr, + &node2_pubkey, + Some(NODE2_PEER_PORT + 10), + Some(500000), + Some(0), + None, + None, + ) + .await; + + let (_preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let InvoiceHodlResponse { invoice, .. } = + invoice_hodl(node2_addr, Some(40_000), 900, payment_hash_hex.clone()).await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + assert_eq!(decoded.payment_hash, payment_hash_hex); + + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + assert!(matches!(invoice_status(node2_addr, &invoice).await, InvoiceStatus::Pending)); + + invoice_cancel(node2_addr, payment_hash_hex.clone()).await; + + let payer_payment = + wait_for_ln_payment(node1_addr, &decoded.payment_hash, HTLCStatus::Failed).await; + assert_eq!(payer_payment.status, HTLCStatus::Failed); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Cancelled).await; + assert_eq!(payee_payment.status, HTLCStatus::Cancelled); + assert!(matches!(invoice_status(node2_addr, &invoice).await, InvoiceStatus::Failed)); +} diff --git a/src/test/mod.rs b/src/test/mod.rs index d4f04a04..7df6473a 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -27,7 +27,8 @@ use crate::routes::{ DisconnectPeerRequest, EmptyResponse, FailTransfersRequest, FailTransfersResponse, GetAssetMediaRequest, GetAssetMediaResponse, GetChannelIdRequest, GetChannelIdResponse, GetPaymentRequest, GetPaymentResponse, GetSwapRequest, GetSwapResponse, HTLCStatus, - InitRequest, InitResponse, InvoiceStatus, InvoiceStatusRequest, InvoiceStatusResponse, + InitRequest, InitResponse, InvoiceCancelRequest, InvoiceHodlRequest, InvoiceHodlResponse, + InvoiceSettleRequest, InvoiceStatus, InvoiceStatusRequest, InvoiceStatusResponse, IssueAssetCFARequest, IssueAssetCFAResponse, IssueAssetNIARequest, IssueAssetNIAResponse, IssueAssetUDARequest, IssueAssetUDAResponse, KeysendRequest, KeysendResponse, LNInvoiceRequest, LNInvoiceResponse, ListAssetsRequest, ListAssetsResponse, ListChannelsResponse, @@ -549,6 +550,57 @@ async fn invoice_status(node_address: SocketAddr, invoice: &str) -> InvoiceStatu .status } +async fn invoice_hodl( + node_address: SocketAddr, + amt_msat: Option, + expiry_sec: u32, + payment_hash: String, +) -> InvoiceHodlResponse { + println!("creating HODL invoice on node {node_address}"); + let payload = InvoiceHodlRequest { + amt_msat, + expiry_sec, + asset_id: None, + asset_amount: None, + payment_hash, + external_ref: None, + }; + let res = reqwest::Client::new() + .post(format!("http://{node_address}/invoice/hodl")) + .json(&payload) + .send() + .await + .unwrap(); + _check_response_is_ok(res).await.json::().await.unwrap() +} + +async fn invoice_settle(node_address: SocketAddr, payment_hash: String, payment_preimage: String) { + println!("settling HODL invoice {payment_hash} on node {node_address}"); + let payload = InvoiceSettleRequest { + payment_hash, + payment_preimage, + }; + let res = reqwest::Client::new() + .post(format!("http://{node_address}/invoice/settle")) + .json(&payload) + .send() + .await + .unwrap(); + _check_response_is_ok(res).await; +} + +async fn invoice_cancel(node_address: SocketAddr, payment_hash: String) { + println!("cancelling HODL invoice {payment_hash} on node {node_address}"); + let payload = InvoiceCancelRequest { payment_hash }; + let res = reqwest::Client::new() + .post(format!("http://{node_address}/invoice/cancel")) + .json(&payload) + .send() + .await + .unwrap(); + _check_response_is_ok(res).await; +} + async fn issue_asset_cfa(node_address: SocketAddr, file_path: Option<&str>) -> AssetCFA { println!("issuing CFA asset on node {node_address}"); let mut file_digest = None; @@ -1806,6 +1858,7 @@ mod concurrent_btc_payments; mod concurrent_openchannel; mod fail_transfers; mod getchannelid; +mod hodl_invoice; mod htlc_amount_checks; mod invoice; mod issue; From e12a101b3f15cde35752053813c5b9df1ede437a Mon Sep 17 00:00:00 2001 From: Xalkan Date: Wed, 31 Dec 2025 12:33:35 -0300 Subject: [PATCH 06/17] =?UTF-8?q?guard=20claimable=20cleanup=20on=20Paymen?= =?UTF-8?q?tClaimed=20and=20make=20invoice=20cancel=20best=E2=80=91effort?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ldk.rs | 5 +++++ src/routes.rs | 21 ++++++--------------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/ldk.rs b/src/ldk.rs index a16d8bb3..1307ef2f 100644 --- a/src/ldk.rs +++ b/src/ldk.rs @@ -1055,6 +1055,11 @@ async fn handle_ldk_events( receiver_node_id.unwrap(), ); } + + // Only HODL invoices create claimable entries; auto-claim payments won't have one. + if unlocked_state.claimable_payment(&payment_hash).is_some() { + let _ = unlocked_state.take_claimable_payment(&payment_hash); + } } Event::PaymentSent { payment_preimage, diff --git a/src/routes.rs b/src/routes.rs index 9362cb71..5d2813ca 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -2754,15 +2754,8 @@ pub(crate) async fn invoice_settle( } } - // All validations passed; now remove the claimable to avoid double-settlement. - let claimed = unlocked_state.channel_manager.claim_funds(preimage); - if !claimed { - // If LDK rejected the claim (e.g. HTLC already timed out/unknown), keep the claimable - // entry so the caller can retry or cancel. - return Err(APIError::InvoiceNotClaimable); - } - // Best-effort cleanup; ignore if another task removed it concurrently. - let _ = unlocked_state.take_claimable_payment(&payment_hash); + // All validations passed; now claim the funds. + unlocked_state.channel_manager.claim_funds(preimage); Ok(Json(EmptyResponse {})) }) @@ -2793,14 +2786,12 @@ pub(crate) async fn invoice_cancel( .claimable_payment(&payment_hash) .ok_or(APIError::InvoiceNotClaimable)?; - let ok = unlocked_state + // Best-effort cancel: LDK doesn't report sync success here, so just clear the + // claimable entry and let later events update status if it was already claimed. + unlocked_state .channel_manager .fail_htlc_backwards(&payment_hash); - if !ok { - // HTLC might already be gone; keep claimable so it can be retried/settled. - return Err(APIError::InvoiceNotClaimable); - } - // Best-effort cleanup after a successful fail; ignore if already removed. + // Best-effort cleanup; ignore if already removed. let _ = unlocked_state.take_claimable_payment(&payment_hash); unlocked_state.upsert_inbound_payment( From 7f424eb5c04a89474c68be9940305c675aac8785 Mon Sep 17 00:00:00 2001 From: Xalkan Date: Fri, 2 Jan 2026 10:22:46 -0300 Subject: [PATCH 07/17] fix: start bitcoind before electrs in regtest setup --- regtest.sh | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/regtest.sh b/regtest.sh index b3452f01..a61f944a 100755 --- a/regtest.sh +++ b/regtest.sh @@ -49,6 +49,20 @@ _wait_for_bitcoind() { done } +_wait_for_bitcoind_rpc() { + # wait for RPC to accept requests + start_time=$(date +%s) + until $BITCOIN_CLI getblockcount >/dev/null 2>&1; do + current_time=$(date +%s) + if [ $((current_time - start_time)) -gt $TIMEOUT ]; then + echo "Timeout waiting for bitcoind RPC to start" + $COMPOSE logs bitcoind + exit 1 + fi + sleep 1 + done +} + _wait_for_electrs() { # wait for electrs to have completed startup start_time=$(date +%s) @@ -74,9 +88,11 @@ _start_services() { _die "port $port is already bound, services can't be started" fi done - $COMPOSE up -d + $COMPOSE up -d bitcoind echo && echo "preparing bitcoind wallet" _wait_for_bitcoind + _wait_for_bitcoind_rpc + $COMPOSE up -d electrs proxy $BITCOIN_CLI createwallet miner >/dev/null $BITCOIN_CLI -rpcwallet=miner -generate $INITIAL_BLOCKS >/dev/null echo "waiting for electrs to have completed startup" From eb7dba8f3864b4f54ea9ea6a04f1c57792f0674e Mon Sep 17 00:00:00 2001 From: Xalkan Date: Fri, 2 Jan 2026 10:25:21 -0300 Subject: [PATCH 08/17] add Cancelled invoice status --- openapi.yaml | 1 + src/routes.rs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/openapi.yaml b/openapi.yaml index 7ce1cc2f..0ccbf4dd 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1615,6 +1615,7 @@ components: enum: - Pending - Succeeded + - Cancelled - Failed - Expired InvoiceStatusRequest: diff --git a/src/routes.rs b/src/routes.rs index 5d2813ca..a0df468b 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -620,6 +620,7 @@ pub(crate) struct InitResponse { pub(crate) enum InvoiceStatus { Pending, Succeeded, + Cancelled, Failed, Expired, } @@ -1793,7 +1794,7 @@ pub(crate) async fn invoice_status( HTLCStatus::Pending => InvoiceStatus::Pending, HTLCStatus::Succeeded => InvoiceStatus::Succeeded, HTLCStatus::Failed => InvoiceStatus::Failed, - HTLCStatus::Cancelled => InvoiceStatus::Failed, + HTLCStatus::Cancelled => InvoiceStatus::Cancelled, }, None => return Err(APIError::UnknownLNInvoice), }; From 9db392906f91eb4218b0888ee3e8f71ca81d46dd Mon Sep 17 00:00:00 2001 From: Xalkan Date: Fri, 2 Jan 2026 12:22:08 -0300 Subject: [PATCH 09/17] test: expand hodl invoice integration tests and helpers --- src/test/hodl_invoice.rs | 357 +++++++++++++++++++++++++++++++++++---- 1 file changed, 323 insertions(+), 34 deletions(-) diff --git a/src/test/hodl_invoice.rs b/src/test/hodl_invoice.rs index f91c1f15..1057f251 100644 --- a/src/test/hodl_invoice.rs +++ b/src/test/hodl_invoice.rs @@ -1,29 +1,41 @@ use bitcoin::hashes::{sha256::Hash as Sha256, Hash}; use rand::RngCore; +use reqwest::StatusCode; +use serde::Serialize; +use std::net::SocketAddr; +use std::path::Path; +use time::OffsetDateTime; + +use crate::{ + disk::{read_claimable_htlcs, CLAIMABLE_HTLCS_FNAME}, + error::APIError, + utils::{hex_str, validate_and_parse_payment_hash, LDK_DIR}, +}; use super::*; const TEST_DIR_BASE: &str = "tmp/hodl_invoice/"; +/// Generate a random preimage and its corresponding payment hash. fn random_preimage_and_hash() -> (String, String) { let mut preimage = [0u8; 32]; rand::thread_rng().fill_bytes(&mut preimage); - let preimage_hex = hex::encode(preimage); - let payment_hash = hex::encode(Sha256::hash(&preimage).to_byte_array()); + let preimage_hex = hex_str(&preimage); + let payment_hash = hex_str(&Sha256::hash(&preimage).to_byte_array()); (preimage_hex, payment_hash) } -#[serial_test::serial] -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[traced_test] -async fn settle_hodl_invoice() { - initialize(); - - let test_dir_base = format!("{TEST_DIR_BASE}settle/"); +async fn setup_two_nodes_with_channel( + test_dir_suffix: &str, + port_offset: u16, +) -> (SocketAddr, SocketAddr, String, String) { + let test_dir_base = format!("{TEST_DIR_BASE}{test_dir_suffix}/"); let test_dir_node1 = format!("{test_dir_base}node1"); let test_dir_node2 = format!("{test_dir_base}node2"); - let (node1_addr, _) = start_node(&test_dir_node1, NODE1_PEER_PORT, false).await; - let (node2_addr, _) = start_node(&test_dir_node2, NODE2_PEER_PORT, false).await; + let node1_port = NODE1_PEER_PORT + port_offset; + let node2_port = NODE2_PEER_PORT + port_offset; + let (node1_addr, _) = start_node(&test_dir_node1, node1_port, false).await; + let (node2_addr, _) = start_node(&test_dir_node2, node2_port, false).await; fund_and_create_utxos(node1_addr, None).await; fund_and_create_utxos(node2_addr, None).await; @@ -32,7 +44,7 @@ async fn settle_hodl_invoice() { let _channel = open_channel( node1_addr, &node2_pubkey, - Some(NODE2_PEER_PORT), + Some(node2_port), Some(500000), Some(0), None, @@ -40,19 +52,154 @@ async fn settle_hodl_invoice() { ) .await; + (node1_addr, node2_addr, test_dir_node1, test_dir_node2) +} + +async fn setup_single_node(test_dir_suffix: &str, port_offset: u16) -> (SocketAddr, String) { + let test_dir_base = format!("{TEST_DIR_BASE}{test_dir_suffix}/"); + let test_dir_node1 = format!("{test_dir_base}node1"); + let node1_port = NODE1_PEER_PORT + port_offset; + let (node1_addr, _) = start_node(&test_dir_node1, node1_port, false).await; + fund_and_create_utxos(node1_addr, None).await; + (node1_addr, test_dir_node1) +} + +async fn invoice_post_expect_error( + node_address: SocketAddr, + path: &str, + payload: &T, + expected_status: StatusCode, + expected_message: &str, + expected_name: &str, +) { + let res = reqwest::Client::new() + .post(format!("http://{node_address}{path}")) + .json(payload) + .send() + .await + .unwrap(); + check_response_is_nok(res, expected_status, expected_message, expected_name).await; +} + +async fn invoice_hodl_expect_error( + node_address: SocketAddr, + amt_msat: Option, + expiry_sec: u32, + payment_hash: String, + expected_status: StatusCode, + expected_message: &str, + expected_name: &str, +) { + println!("creating HODL invoice on node {node_address}"); + let payload = InvoiceHodlRequest { + amt_msat, + expiry_sec, + asset_id: None, + asset_amount: None, + payment_hash, + external_ref: None, + }; + invoice_post_expect_error( + node_address, + "/invoice/hodl", + &payload, + expected_status, + expected_message, + expected_name, + ) + .await; +} + +async fn invoice_settle_expect_error( + node_address: SocketAddr, + payment_hash: String, + payment_preimage: String, + expected_status: StatusCode, + expected_message: &str, + expected_name: &str, +) { + println!("settling HODL invoice {payment_hash} on node {node_address}"); + let payload = InvoiceSettleRequest { + payment_hash, + payment_preimage, + }; + invoice_post_expect_error( + node_address, + "/invoice/settle", + &payload, + expected_status, + expected_message, + expected_name, + ) + .await; +} + +fn expect_api_ok(result: Result, context: &str) -> T { + match result { + Ok(value) => value, + Err(err) => panic!("{context}: {err}"), + } +} + +/// Check if the claimable HTLC entry exists in the node's on-disk store. +fn claimable_exists(node_test_dir: &str, payment_hash_hex: &str) -> Result { + let claimable_path = Path::new(node_test_dir) + .join(LDK_DIR) + .join(CLAIMABLE_HTLCS_FNAME); + let storage = read_claimable_htlcs(&claimable_path); + let hash = validate_and_parse_payment_hash(payment_hash_hex)?; + Ok(storage.payments.contains_key(&hash)) +} + +/// Poll until the claimable entry appears or disappears (bounded by timeout). +async fn wait_for_claimable_state( + node_test_dir: &str, + payment_hash_hex: &str, + expected: bool, +) -> Result<(), APIError> { + let t_0 = OffsetDateTime::now_utc(); + loop { + if claimable_exists(node_test_dir, payment_hash_hex)? == expected { + return Ok(()); + } + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 20.0 { + return Err(APIError::Unexpected(format!( + "claimable entry for {payment_hash_hex} did not reach state {expected}" + ))); + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn settle_hodl_invoice() { + initialize(); + + // Arrange: start two nodes, fund, and open a channel. + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2) = + setup_two_nodes_with_channel("settle", 0).await; + + // Arrange: create a HODL invoice with a fixed payment hash. let (preimage_hex, payment_hash_hex) = random_preimage_and_hash(); let InvoiceHodlResponse { invoice, .. } = invoice_hodl(node2_addr, Some(50_000), 900, payment_hash_hex.clone()).await; let decoded = decode_ln_invoice(node1_addr, &invoice).await; assert_eq!(decoded.payment_hash, payment_hash_hex); - // Payer sees the payment pending until settle is called. + // Act: pay the invoice; HODL keeps it pending and claimable. let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; assert!(matches!(invoice_status(node2_addr, &invoice).await, InvoiceStatus::Pending)); + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, + "wait for claimable entry to appear", + ); - // Settle with the chosen preimage. + // Act: settle with the chosen preimage. invoice_settle(node2_addr, payment_hash_hex.clone(), preimage_hex.clone()).await; + // Assert: payer/payee succeed and claimable entry is removed. let payer_payment = wait_for_ln_payment(node1_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; assert_eq!(payer_payment.status, HTLCStatus::Succeeded); @@ -60,6 +207,10 @@ async fn settle_hodl_invoice() { wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; assert_eq!(payee_payment.status, HTLCStatus::Succeeded); assert!(matches!(invoice_status(node2_addr, &invoice).await, InvoiceStatus::Succeeded)); + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, false).await, + "wait for claimable entry to be removed", + ); } #[serial_test::serial] @@ -68,43 +219,181 @@ async fn settle_hodl_invoice() { async fn cancel_hodl_invoice() { initialize(); - let test_dir_base = format!("{TEST_DIR_BASE}cancel/"); - let test_dir_node1 = format!("{test_dir_base}node1"); - let test_dir_node2 = format!("{test_dir_base}node2"); - let (node1_addr, _) = start_node(&test_dir_node1, NODE1_PEER_PORT + 10, false).await; - let (node2_addr, _) = start_node(&test_dir_node2, NODE2_PEER_PORT + 10, false).await; - - fund_and_create_utxos(node1_addr, None).await; - fund_and_create_utxos(node2_addr, None).await; - - let node2_pubkey = node_info(node2_addr).await.pubkey; - let _channel = open_channel( - node1_addr, - &node2_pubkey, - Some(NODE2_PEER_PORT + 10), - Some(500000), - Some(0), - None, - None, - ) - .await; + // Arrange: start two nodes, fund, and open a channel. + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2) = + setup_two_nodes_with_channel("cancel", 10).await; + // Arrange: create a HODL invoice with a fixed payment hash. let (_preimage_hex, payment_hash_hex) = random_preimage_and_hash(); let InvoiceHodlResponse { invoice, .. } = invoice_hodl(node2_addr, Some(40_000), 900, payment_hash_hex.clone()).await; let decoded = decode_ln_invoice(node1_addr, &invoice).await; assert_eq!(decoded.payment_hash, payment_hash_hex); + // Act: pay the invoice; it should be pending and claimable. let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; assert!(matches!(invoice_status(node2_addr, &invoice).await, InvoiceStatus::Pending)); + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, + "wait for claimable entry to appear", + ); + // Act: cancel and fail back the HTLC. invoice_cancel(node2_addr, payment_hash_hex.clone()).await; + // Assert: payer fails, payee cancels, and claimable entry is removed. let payer_payment = wait_for_ln_payment(node1_addr, &decoded.payment_hash, HTLCStatus::Failed).await; assert_eq!(payer_payment.status, HTLCStatus::Failed); let payee_payment = wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Cancelled).await; assert_eq!(payee_payment.status, HTLCStatus::Cancelled); + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Cancelled + )); + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, false).await, + "wait for claimable entry to be removed", + ); +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn expire_hodl_invoice() { + initialize(); + + // Arrange: start two nodes, fund, and open a channel. + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2) = + setup_two_nodes_with_channel("expiry", 20).await; + + // Arrange: create a short-expiry HODL invoice (20s). + let (_preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + // Use a small-but-not-too-small expiry to let the payment reach Pending + // before the background expiry task fails it. + let InvoiceHodlResponse { invoice, .. } = + invoice_hodl(node2_addr, Some(30_000), 20, payment_hash_hex.clone()).await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + assert_eq!(decoded.payment_hash, payment_hash_hex); + + // Act: pay and wait for the background expiry task to fail the HTLC. + // Timing note: expiry is 20s, the expiry task runs every 30s, and the payment wait timeout + // is 40s, so this should succeed on the next expiry tick. + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, + "wait for claimable entry to appear", + ); + + // Assert: both sides see Failed and claimable entry is removed. + let payer_payment = + wait_for_ln_payment(node1_addr, &decoded.payment_hash, HTLCStatus::Failed).await; + assert_eq!(payer_payment.status, HTLCStatus::Failed); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Failed).await; + assert_eq!(payee_payment.status, HTLCStatus::Failed); assert!(matches!(invoice_status(node2_addr, &invoice).await, InvoiceStatus::Failed)); + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, false).await, + "wait for claimable entry to be removed", + ); +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn reject_wrong_preimage_settle() { + initialize(); + + // Arrange: start two nodes, fund, and open a channel. + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2) = + setup_two_nodes_with_channel("wrong_preimage", 30).await; + + // Arrange: create a HODL invoice and pay it (pending). + let (_preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let InvoiceHodlResponse { invoice, .. } = + invoice_hodl(node2_addr, Some(35_000), 900, payment_hash_hex.clone()).await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + assert_eq!(decoded.payment_hash, payment_hash_hex); + + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, + "wait for claimable entry to appear", + ); + + // Act: try to settle with a mismatching preimage. + let (wrong_preimage_hex, _) = random_preimage_and_hash(); + invoice_settle_expect_error( + node2_addr, + payment_hash_hex.clone(), + wrong_preimage_hex, + StatusCode::BAD_REQUEST, + "Invalid payment preimage", + "InvalidPaymentPreimage", + ) + .await; + + // Assert: invoice stays pending and claimable entry remains. + assert!(matches!(invoice_status(node2_addr, &invoice).await, InvoiceStatus::Pending)); + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, + "wait for claimable entry to remain", + ); +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn reject_duplicate_hodl_payment_hash() { + initialize(); + + // Arrange: start a node and fund it. + let (node1_addr, _test_dir_node1) = setup_single_node("duplicate_hash", 40).await; + + // Arrange: create the first HODL invoice. + let (_preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let InvoiceHodlResponse { invoice, .. } = + invoice_hodl(node1_addr, Some(20_000), 900, payment_hash_hex.clone()).await; + + // Act: attempt to create another HODL invoice with the same hash. + invoice_hodl_expect_error( + node1_addr, + Some(20_000), + 900, + payment_hash_hex.clone(), + StatusCode::BAD_REQUEST, + "Payment hash already used", + "PaymentHashAlreadyUsed", + ) + .await; + + // Assert: the original invoice remains pending. + assert!(matches!(invoice_status(node1_addr, &invoice).await, InvoiceStatus::Pending)); +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn auto_claim_invoice_regression() { + initialize(); + + // Arrange: start two nodes, fund, and open a channel. + let (node1_addr, node2_addr, _test_dir_node1, _test_dir_node2) = + setup_two_nodes_with_channel("autoclaim", 50).await; + + // Act: create and pay a normal (auto-claim) invoice. + let LNInvoiceResponse { invoice } = ln_invoice(node2_addr, Some(25_000), None, None, 900).await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Succeeded).await; + // Assert: both sides succeed and invoice status updates. + let payer_payment = + wait_for_ln_payment(node1_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert_eq!(payer_payment.status, HTLCStatus::Succeeded); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert_eq!(payee_payment.status, HTLCStatus::Succeeded); + assert!(matches!(invoice_status(node2_addr, &invoice).await, InvoiceStatus::Succeeded)); } From a07a1ea64ac999c6a71d262f13946d5caeec92d6 Mon Sep 17 00:00:00 2001 From: Xalkan Date: Mon, 5 Jan 2026 13:03:34 -0300 Subject: [PATCH 10/17] fix: race condition in hodl invoice settlement Add mark_claimable_settling() to atomically mark claimable HTLCs as "settling" before calling claim_funds(), preventing the background expiry task from racing to fail_htlc_backwards on the same payment hash. --- src/ldk.rs | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/routes.rs | 26 ++++++----------------- 2 files changed, 65 insertions(+), 20 deletions(-) diff --git a/src/ldk.rs b/src/ldk.rs index 1307ef2f..d6900059 100644 --- a/src/ldk.rs +++ b/src/ldk.rs @@ -179,6 +179,10 @@ pub(crate) struct ClaimablePayment { pub(crate) claim_deadline_height: Option, /// When we stored this claimable (seconds since epoch). pub(crate) created_at: u64, + /// Whether a settle is currently in-flight (prevents expiry task from failing it). + pub(crate) settling: Option, + /// When settlement was initiated (seconds since epoch), used to time out stalled settlements. + pub(crate) settling_since: Option, } impl_writeable_tlv_based!(ClaimablePayment, { @@ -187,6 +191,8 @@ impl_writeable_tlv_based!(ClaimablePayment, { (4, invoice_expiry, required), (6, claim_deadline_height, required), (8, created_at, required), + (10, settling, option), + (12, settling_since, option), }); pub(crate) struct InboundPaymentInfoStorage { @@ -246,6 +252,18 @@ impl UnlockedAppState { .payments .iter() .filter_map(|(hash, c)| { + if c.settling.unwrap_or(false) { + // Settlement in-flight; allow a timeout (24h) to avoid stuck entries. + if let Some(since) = c.settling_since { + if now_ts.saturating_sub(since) > 86_400 { + // Timeout, let it expire. + } else { + return None; + } + } else { + return None; + } + } let deadline_passed = c .claim_deadline_height .map(|h| current_height >= h) @@ -377,6 +395,45 @@ impl UnlockedAppState { res } + /// Mark a claimable HTLC as settling after revalidating expiry/deadline. + /// Keeps the entry so PaymentClaimed can remove it; expiry task will skip while settling=true. + pub(crate) fn mark_claimable_settling( + &self, + payment_hash: &PaymentHash, + invoice_expiry: Option, + ) -> Result { + let mut claimables = self.get_claimable_htlcs(); + let Some(claimable) = claimables.payments.get_mut(payment_hash) else { + return Err(APIError::InvoiceNotClaimable); + }; + + let current_height = self + .channel_manager + .current_best_block() + .height; + let now_ts = get_current_timestamp(); + + if let Some(deadline_height) = claimable.claim_deadline_height { + if current_height >= deadline_height { + return Err(APIError::ClaimDeadlineExceeded); + } + } + + if let Some(expiry) = invoice_expiry { + if now_ts >= expiry { + return Err(APIError::InvoiceExpired); + } + } + + // Persist the settling flag so the expiry task won't race and fail it backwards. + claimable.settling = Some(true); + claimable.settling_since = Some(now_ts); + let claimable_clone = claimable.clone(); + self.save_claimable_htlcs(claimables); + + Ok(claimable_clone) + } + pub(crate) fn claimable_payment(&self, payment_hash: &PaymentHash) -> Option { self.get_claimable_htlcs().payments.get(payment_hash).cloned() } @@ -995,6 +1052,8 @@ async fn handle_ldk_events( invoice_expiry: metadata.expiry, claim_deadline_height, created_at: now_ts, + settling: Some(false), + settling_since: None, }; unlocked_state.upsert_claimable_payment(claimable); unlocked_state.upsert_inbound_payment( diff --git a/src/routes.rs b/src/routes.rs index a0df468b..81e67525 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -2734,26 +2734,12 @@ pub(crate) async fn invoice_settle( return Err(APIError::InvoiceNotHodl); } - let claimable = unlocked_state - .claimable_payment(&payment_hash) - .ok_or(APIError::InvoiceNotClaimable)?; - - let current_height = unlocked_state - .channel_manager - .current_best_block() - .height; - if let Some(deadline_height) = claimable.claim_deadline_height { - if current_height >= deadline_height { - return Err(APIError::ClaimDeadlineExceeded); - } - } - - let now = get_current_timestamp(); - if let Some(expiry) = metadata.expiry { - if now >= expiry { - return Err(APIError::InvoiceExpired); - } - } + // Atomically take the claimable entry so the expiry task cannot fail it between + // validation and claim_funds. + let _claimable = unlocked_state.mark_claimable_settling( + &payment_hash, + metadata.expiry, + )?; // All validations passed; now claim the funds. unlocked_state.channel_manager.claim_funds(preimage); From 403c3f76e6ab9948fab1ceb552e2996e069c1724 Mon Sep 17 00:00:00 2001 From: Boris Date: Fri, 2 Jan 2026 19:35:40 -0500 Subject: [PATCH 11/17] add more tests for HODL invoices settle_twice_succeeds: if the invoice is already settled, settling will succeed; cancel_then_settle_fails: cancel first, settle must fail; settle_then_cancel_fails: settle first, cancel must fail; expire_hodl_invoice: short-expiry path; settle/cancel fail after expiry; expire_hodl_invoice_by_blocks: block-driven expiry; settle/cancel fail after; reject_wrong_preimage_settle: good preimage still suceeds. --- src/test/hodl_invoice.rs | 261 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 260 insertions(+), 1 deletion(-) diff --git a/src/test/hodl_invoice.rs b/src/test/hodl_invoice.rs index 1057f251..34140a5f 100644 --- a/src/test/hodl_invoice.rs +++ b/src/test/hodl_invoice.rs @@ -134,6 +134,26 @@ async fn invoice_settle_expect_error( .await; } +async fn invoice_cancel_expect_error( + node_address: SocketAddr, + payment_hash: String, + expected_status: StatusCode, + expected_message: &str, + expected_name: &str, +) { + println!("cancelling HODL invoice {payment_hash} on node {node_address}"); + let payload = InvoiceCancelRequest { payment_hash }; + invoice_post_expect_error( + node_address, + "/invoice/cancel", + &payload, + expected_status, + expected_message, + expected_name, + ) + .await; +} + fn expect_api_ok(result: Result, context: &str) -> T { match result { Ok(value) => value, @@ -151,6 +171,22 @@ fn claimable_exists(node_test_dir: &str, payment_hash_hex: &str) -> Result Result, APIError> { + let claimable_path = Path::new(node_test_dir) + .join(LDK_DIR) + .join(CLAIMABLE_HTLCS_FNAME); + let storage = read_claimable_htlcs(&claimable_path); + let hash = validate_and_parse_payment_hash(payment_hash_hex)?; + Ok(storage + .payments + .get(&hash) + .and_then(|c| c.claim_deadline_height)) +} + /// Poll until the claimable entry appears or disappears (bounded by timeout). async fn wait_for_claimable_state( node_test_dir: &str, @@ -213,6 +249,51 @@ async fn settle_hodl_invoice() { ); } +/// Idempotency: settling twice should both succeed (LDK/LND behavior). +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn settle_twice_succeeds() { + initialize(); + + // Arrange: start two nodes, fund, and open a channel. + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2) = + setup_two_nodes_with_channel("settle-twice", 5).await; + + // Arrange: create a HODL invoice with a fixed payment hash. + let (preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let InvoiceHodlResponse { invoice, .. } = + invoice_hodl(node2_addr, Some(45_000), 900, payment_hash_hex.clone()).await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + assert_eq!(decoded.payment_hash, payment_hash_hex); + + // Act: pay the invoice; HODL keeps it pending and claimable. + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, + "wait for claimable entry to appear", + ); + + // Act: first settle with the chosen preimage. + invoice_settle(node2_addr, payment_hash_hex.clone(), preimage_hex.clone()).await; + + // Assert: payer/payee succeed and claimable entry may be cleaned up later. + let payer_payment = + wait_for_ln_payment(node1_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert_eq!(payer_payment.status, HTLCStatus::Succeeded); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert_eq!(payee_payment.status, HTLCStatus::Succeeded); + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Succeeded + )); + + // Act: settle again with the same preimage; should be idempotent success. + invoice_settle(node2_addr, payment_hash_hex.clone(), preimage_hex.clone()).await; +} + +/// Cancel and then try to cancel again (the second call fails). #[serial_test::serial] #[tokio::test(flavor = "multi_thread", worker_threads = 1)] #[traced_test] @@ -256,8 +337,93 @@ async fn cancel_hodl_invoice() { wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, false).await, "wait for claimable entry to be removed", ); + + // Duplicate cancel should fail. + invoice_cancel_expect_error( + node2_addr, + payment_hash_hex, + StatusCode::FORBIDDEN, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; } +/// Cacelling first must make a later settle fail (already cancelled). +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn cancel_then_settle_fails() { + initialize(); + + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2) = + setup_two_nodes_with_channel("cancel-settle", 11).await; + + let (preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let InvoiceHodlResponse { invoice, .. } = + invoice_hodl(node2_addr, Some(40_000), 900, payment_hash_hex.clone()).await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, + "claimable entry should appear", + ); + + invoice_cancel(node2_addr, payment_hash_hex.clone()).await; + + invoice_settle_expect_error( + node2_addr, + payment_hash_hex.clone(), + preimage_hex, + StatusCode::FORBIDDEN, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; + + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Cancelled).await; + assert_eq!(payee_payment.status, HTLCStatus::Cancelled); +} + +/// Settling first must make a later cancel fail (already settled). +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn settle_then_cancel_fails() { + initialize(); + + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2) = + setup_two_nodes_with_channel("settle-cancel", 12).await; + + let (preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let InvoiceHodlResponse { invoice, .. } = + invoice_hodl(node2_addr, Some(42_000), 900, payment_hash_hex.clone()).await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, + "claimable entry should appear", + ); + + invoice_settle(node2_addr, payment_hash_hex.clone(), preimage_hex).await; + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert_eq!(payee_payment.status, HTLCStatus::Succeeded); + + invoice_cancel_expect_error( + node2_addr, + payment_hash_hex.clone(), + StatusCode::FORBIDDEN, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; +} + +/// Expiry via short invoice timeout: ensure settle/cancel fail after expiry. #[serial_test::serial] #[tokio::test(flavor = "multi_thread", worker_threads = 1)] #[traced_test] @@ -298,6 +464,93 @@ async fn expire_hodl_invoice() { wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, false).await, "wait for claimable entry to be removed", ); + + // After expiry, settle/cancel should fail. + invoice_settle_expect_error( + node2_addr, + payment_hash_hex.clone(), + _preimage_hex, + StatusCode::FORBIDDEN, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; + invoice_cancel_expect_error( + node2_addr, + payment_hash_hex, + StatusCode::FORBIDDEN, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; +} + +/// Expiry driven by CLTV/blocks: mine past deadline, then settle/cancel must fail. +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn expire_hodl_invoice_by_blocks() { + initialize(); + + // Arrange: start two nodes, fund, and open a channel. + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2) = + setup_two_nodes_with_channel("expiry-blocks", 25).await; + + // Arrange: create a HODL invoice with standard expiry. + let (_preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let InvoiceHodlResponse { invoice, .. } = + invoice_hodl(node2_addr, Some(30_000), 900, payment_hash_hex.clone()).await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + assert_eq!(decoded.payment_hash, payment_hash_hex); + + // Pay and wait for claimable. + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, + "wait for claimable entry to appear", + ); + + // Mine past the claim deadline height (reported by LDK) to force timeout, then + // give the 30s expiry task a chance to sweep it. + let deadline_height = claimable_deadline_height(&test_dir_node2, &payment_hash_hex) + .expect("read deadline height") + .expect("expected claim_deadline_height for claimable HTLC"); + let current_height = super::get_block_count(); + let blocks_to_mine = deadline_height.saturating_sub(current_height) + 2; + super::mine_n_blocks(false, blocks_to_mine as u16); + tokio::time::sleep(std::time::Duration::from_secs(35)).await; + + // Assert: both sides see Failed and claimable entry is removed. + let payer_payment = + wait_for_ln_payment(node1_addr, &decoded.payment_hash, HTLCStatus::Failed).await; + assert_eq!(payer_payment.status, HTLCStatus::Failed); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Failed).await; + assert_eq!(payee_payment.status, HTLCStatus::Failed); + assert!(matches!(invoice_status(node2_addr, &invoice).await, InvoiceStatus::Failed)); + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, false).await, + "wait for claimable entry to be removed", + ); + + // After expiry, settle/cancel should fail. + invoice_settle_expect_error( + node2_addr, + payment_hash_hex.clone(), + _preimage_hex, + StatusCode::FORBIDDEN, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; + invoice_cancel_expect_error( + node2_addr, + payment_hash_hex, + StatusCode::FORBIDDEN, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; } #[serial_test::serial] @@ -311,7 +564,7 @@ async fn reject_wrong_preimage_settle() { setup_two_nodes_with_channel("wrong_preimage", 30).await; // Arrange: create a HODL invoice and pay it (pending). - let (_preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let (good_preimage_hex, payment_hash_hex) = random_preimage_and_hash(); let InvoiceHodlResponse { invoice, .. } = invoice_hodl(node2_addr, Some(35_000), 900, payment_hash_hex.clone()).await; let decoded = decode_ln_invoice(node1_addr, &invoice).await; @@ -341,6 +594,12 @@ async fn reject_wrong_preimage_settle() { wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, "wait for claimable entry to remain", ); + + // Now settle with the correct preimage; should succeed and clean up. + invoice_settle(node2_addr, payment_hash_hex.clone(), good_preimage_hex).await; + let _ = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert!(matches!(invoice_status(node2_addr, &invoice).await, InvoiceStatus::Succeeded)); } #[serial_test::serial] From 66fd338c1fbd91838d520b8a67a7ddae93d98f18 Mon Sep 17 00:00:00 2001 From: Boris Date: Fri, 2 Jan 2026 20:23:38 -0500 Subject: [PATCH 12/17] write description for invoice_settle --- src/routes.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/routes.rs b/src/routes.rs index 81e67525..27fcb323 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -2713,6 +2713,11 @@ pub(crate) async fn invoice_hodl( .await } +/// Settle a HODL invoice that currently has a held HTLC. Requires the invoice +/// `payment_hash` and the matching 32-byte `payment_preimage`. Fails if the +/// invoice is not HODL, there is no claimable HTLC (already cancelled, expired +/// or failed), the HTLC has timed out, or the preimage doesn't match. +/// If the invoice is already settled, this call succeeds (idempotent). pub(crate) async fn invoice_settle( State(state): State>, WithRejection(Json(payload), _): WithRejection, APIError>, From f7e2eb71e107eafc37328668c374ceb33c9063d5 Mon Sep 17 00:00:00 2001 From: Xalkan Date: Mon, 5 Jan 2026 15:40:31 -0300 Subject: [PATCH 13/17] support idempotent settlement for already-settled hodl invoices Make invoice_settle() idempotent by first checking whether the payment has already succeeded before marking the claimable as settling. This enables safe retries after the claimable entry has been cleaned up by the PaymentClaimed event. --- src/routes.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/routes.rs b/src/routes.rs index 27fcb323..2d7c4e26 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -2739,6 +2739,20 @@ pub(crate) async fn invoice_settle( return Err(APIError::InvoiceNotHodl); } + // Idempotent path: if this payment already succeeded, validate preimage and return OK. + // This avoids failing when the claimable entry has already been cleaned up by PaymentClaimed. + if let Some(existing) = unlocked_state.inbound_payments().get(&payment_hash) { + if matches!(existing.status, HTLCStatus::Succeeded) { + if let Some(stored_preimage) = existing.preimage { + if stored_preimage != preimage { + return Err(APIError::InvalidPaymentPreimage); + } + } + // Already settled with matching preimage; idempotent success. + return Ok(Json(EmptyResponse {})); + } + } + // Atomically take the claimable entry so the expiry task cannot fail it between // validation and claim_funds. let _claimable = unlocked_state.mark_claimable_settling( From 6b8727c9120ae158039daa003f34d3d0f45bbded Mon Sep 17 00:00:00 2001 From: dcorral Date: Fri, 9 Jan 2026 11:40:33 +0100 Subject: [PATCH 14/17] Apply formatting and modify gh action to apply formatting in all push/pr --- .github/workflows/format.yaml | 2 -- src/error.rs | 3 +-- src/ldk.rs | 47 +++++++++++++++++++--------------- src/main.rs | 13 +++++----- src/routes.rs | 39 ++++++++++++++-------------- src/test/hodl_invoice.rs | 48 +++++++++++++++++++++++++++-------- src/test/mod.rs | 6 ++++- src/utils.rs | 8 +++--- 8 files changed, 99 insertions(+), 67 deletions(-) diff --git a/.github/workflows/format.yaml b/.github/workflows/format.yaml index 39cc8e63..7f11eedf 100644 --- a/.github/workflows/format.yaml +++ b/.github/workflows/format.yaml @@ -2,9 +2,7 @@ name: Format code on: push: - branches: [ "master" ] pull_request: - branches: [ "master" ] env: CARGO_TERM_COLOR: always diff --git a/src/error.rs b/src/error.rs index 0ab92f8e..5cfd80a6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -521,8 +521,7 @@ impl IntoResponse for APIError { | APIError::UnsupportedTransportType => { (StatusCode::FORBIDDEN, self.to_string(), self.name()) } - | APIError::InvoiceNotHodl - | APIError::InvoiceNotClaimable => { + APIError::InvoiceNotHodl | APIError::InvoiceNotClaimable => { (StatusCode::FORBIDDEN, self.to_string(), self.name()) } APIError::Network(_) | APIError::NoValidTransportEndpoint => ( diff --git a/src/ldk.rs b/src/ldk.rs index d6900059..56415b5b 100644 --- a/src/ldk.rs +++ b/src/ldk.rs @@ -245,7 +245,11 @@ impl_writeable_tlv_based!(ChannelIdsMap, { impl UnlockedAppState { /// Remove and return claimables that are expired or past deadline. - pub(crate) fn expire_claimables(&self, now_ts: u64, current_height: u32) -> Vec { + pub(crate) fn expire_claimables( + &self, + now_ts: u64, + current_height: u32, + ) -> Vec { let mut claimables = self.get_claimable_htlcs(); let mut expired = vec![]; let to_remove: Vec = claimables @@ -268,10 +272,7 @@ impl UnlockedAppState { .claim_deadline_height .map(|h| current_height >= h) .unwrap_or(false); - let invoice_expired = c - .invoice_expiry - .map(|e| now_ts >= e) - .unwrap_or(false); + let invoice_expired = c.invoice_expiry.map(|e| now_ts >= e).unwrap_or(false); if deadline_passed || invoice_expired { Some(*hash) } else { @@ -365,7 +366,11 @@ impl UnlockedAppState { self.save_inbound_payments(inbound); } - pub(crate) fn add_invoice_metadata(&self, payment_hash: PaymentHash, metadata: InvoiceMetadata) { + pub(crate) fn add_invoice_metadata( + &self, + payment_hash: PaymentHash, + metadata: InvoiceMetadata, + ) { let mut invoices = self.get_invoice_metadata(); invoices.invoices.insert(payment_hash, metadata); self.save_invoice_metadata(invoices); @@ -407,10 +412,7 @@ impl UnlockedAppState { return Err(APIError::InvoiceNotClaimable); }; - let current_height = self - .channel_manager - .current_best_block() - .height; + let current_height = self.channel_manager.current_best_block().height; let now_ts = get_current_timestamp(); if let Some(deadline_height) = claimable.claim_deadline_height { @@ -435,7 +437,10 @@ impl UnlockedAppState { } pub(crate) fn claimable_payment(&self, payment_hash: &PaymentHash) -> Option { - self.get_claimable_htlcs().payments.get(payment_hash).cloned() + self.get_claimable_htlcs() + .payments + .get(payment_hash) + .cloned() } pub(crate) fn add_outbound_payment( @@ -905,7 +910,7 @@ async fn handle_ldk_events( // Check if this payment_hash exists in inbound_payments (legacy invoice) let inbound_payments = unlocked_state.inbound_payments(); let legacy_invoice = inbound_payments.get(&payment_hash); - + if let Some(legacy_payment_info) = legacy_invoice { // This is a legacy invoice (created before invoice_metadata feature) // Treat it as auto-claim invoice with basic validation @@ -913,7 +918,7 @@ async fn handle_ldk_events( "Legacy invoice detected (no metadata) for payment {:?}, treating as auto-claim", payment_hash ); - + // For legacy invoices, LDK should provide the preimage for standard Bolt11 invoices let Some(preimage) = payment_preimage else { // If preimage is missing, check if it was stored in inbound_payment @@ -926,7 +931,7 @@ async fn handle_ldk_events( unlocked_state.channel_manager.claim_funds(stored_preimage); return Ok(()); } - + tracing::error!( "Missing payment preimage for legacy invoice {:?}, cannot claim. \ This may indicate a corrupted state or LDK version issue.", @@ -937,7 +942,7 @@ async fn handle_ldk_events( .fail_htlc_backwards(&payment_hash); return Ok(()); }; - + // Basic validation: check amount if specified in legacy invoice if let Some(expected_amt) = legacy_payment_info.amt_msat { if amount_msat < expected_amt { @@ -961,13 +966,13 @@ async fn handle_ldk_events( return Ok(()); } } - + // Auto-claim legacy invoice tracing::info!("Auto-claiming legacy invoice {:?}", payment_hash); unlocked_state.channel_manager.claim_funds(preimage); return Ok(()); } - + // No metadata and not in inbound_payments - likely spontaneous/keysend payment let Some(preimage) = payment_preimage else { tracing::error!( @@ -1045,7 +1050,7 @@ async fn handle_ldk_events( } InvoiceMode::Hodl => { let claim_deadline_height = claim_deadline.map(|h| h); - + let claimable = ClaimablePayment { payment_hash, amount_msat, @@ -2451,20 +2456,20 @@ pub(crate) async fn start_ldk( // at the same time, but the mutex in get_claimable_htlcs() protects against this. // If user already took it via take_claimable_payment(), expire_claimables() won't // return it, so this is safe. - + tracing::info!( "Expiring claimable payment {:?} (deadline: {:?}, expiry: {:?})", claimable.payment_hash, claimable.claim_deadline_height, claimable.invoice_expiry ); - + // Fail the HTLC backwards - this may be a no-op if already claimed/failed, // but LDK should handle that gracefully unlocked_state_claimable .channel_manager .fail_htlc_backwards(&claimable.payment_hash); - + // Update payment status to Failed unlocked_state_claimable.upsert_inbound_payment( claimable.payment_hash, diff --git a/src/main.rs b/src/main.rs index a4fefb6c..55ce94da 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,12 +45,13 @@ use crate::routes::{ address, asset_balance, asset_metadata, backup, btc_balance, change_password, check_indexer_url, check_proxy_endpoint, close_channel, connect_peer, create_utxos, decode_ln_invoice, decode_rgb_invoice, disconnect_peer, estimate_fee, fail_transfers, - get_asset_media, get_channel_id, get_payment, get_swap, init, invoice_status, issue_asset_cfa, - issue_asset_nia, issue_asset_uda, keysend, list_assets, list_channels, list_payments, - list_peers, list_swaps, list_transactions, list_transfers, list_unspents, ln_invoice, lock, - maker_execute, maker_init, network_info, node_info, open_channel, post_asset_media, - refresh_transfers, restore, revoke_token, rgb_invoice, send_asset, send_btc, - send_onion_message, send_payment, shutdown, sign_message, sync, taker, unlock, invoice_hodl, invoice_settle, invoice_cancel + get_asset_media, get_channel_id, get_payment, get_swap, init, invoice_cancel, invoice_hodl, + invoice_settle, invoice_status, issue_asset_cfa, issue_asset_nia, issue_asset_uda, keysend, + list_assets, list_channels, list_payments, list_peers, list_swaps, list_transactions, + list_transfers, list_unspents, ln_invoice, lock, maker_execute, maker_init, network_info, + node_info, open_channel, post_asset_media, refresh_transfers, restore, revoke_token, + rgb_invoice, send_asset, send_btc, send_onion_message, send_payment, shutdown, sign_message, + sync, taker, unlock, }; use crate::utils::{start_daemon, AppState, LOGS_DIR}; diff --git a/src/routes.rs b/src/routes.rs index 2d7c4e26..df5ab9de 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -2661,21 +2661,22 @@ pub(crate) async fn invoice_hodl( .duration_since(SystemTime::UNIX_EPOCH) .map_err(|_| APIError::FailedInvoiceCreation("system time before UNIX_EPOCH".into()))?; - let invoice = create_invoice_from_channelmanager_and_duration_since_epoch_with_payment_hash( - &unlocked_state.channel_manager, - unlocked_state.keys_manager.clone(), - state.static_state.logger.clone(), - currency, - payload.amt_msat, - "ldk-tutorial-node".to_string(), - duration_since_epoch, - payload.expiry_sec, - payment_hash, - None, - contract_id, - payload.asset_amount, - ) - .map_err(|e| APIError::FailedInvoiceCreation(e.to_string()))?; + let invoice = + create_invoice_from_channelmanager_and_duration_since_epoch_with_payment_hash( + &unlocked_state.channel_manager, + unlocked_state.keys_manager.clone(), + state.static_state.logger.clone(), + currency, + payload.amt_msat, + "ldk-tutorial-node".to_string(), + duration_since_epoch, + payload.expiry_sec, + payment_hash, + None, + contract_id, + payload.asset_amount, + ) + .map_err(|e| APIError::FailedInvoiceCreation(e.to_string()))?; let created_at = get_current_timestamp(); unlocked_state.add_inbound_payment( @@ -2727,7 +2728,8 @@ pub(crate) async fn invoice_settle( let unlocked_state = guard.as_ref().unwrap(); let payment_hash = validate_and_parse_payment_hash(&payload.payment_hash)?; - let preimage = validate_and_parse_payment_preimage(&payload.payment_preimage, &payment_hash)?; + let preimage = + validate_and_parse_payment_preimage(&payload.payment_preimage, &payment_hash)?; let metadata = unlocked_state .invoice_metadata() @@ -2755,10 +2757,7 @@ pub(crate) async fn invoice_settle( // Atomically take the claimable entry so the expiry task cannot fail it between // validation and claim_funds. - let _claimable = unlocked_state.mark_claimable_settling( - &payment_hash, - metadata.expiry, - )?; + let _claimable = unlocked_state.mark_claimable_settling(&payment_hash, metadata.expiry)?; // All validations passed; now claim the funds. unlocked_state.channel_manager.claim_funds(preimage); diff --git a/src/test/hodl_invoice.rs b/src/test/hodl_invoice.rs index 34140a5f..cce7c0b5 100644 --- a/src/test/hodl_invoice.rs +++ b/src/test/hodl_invoice.rs @@ -226,7 +226,10 @@ async fn settle_hodl_invoice() { // Act: pay the invoice; HODL keeps it pending and claimable. let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; - assert!(matches!(invoice_status(node2_addr, &invoice).await, InvoiceStatus::Pending)); + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Pending + )); expect_api_ok( wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, "wait for claimable entry to appear", @@ -242,7 +245,10 @@ async fn settle_hodl_invoice() { let payee_payment = wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; assert_eq!(payee_payment.status, HTLCStatus::Succeeded); - assert!(matches!(invoice_status(node2_addr, &invoice).await, InvoiceStatus::Succeeded)); + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Succeeded + )); expect_api_ok( wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, false).await, "wait for claimable entry to be removed", @@ -313,7 +319,10 @@ async fn cancel_hodl_invoice() { // Act: pay the invoice; it should be pending and claimable. let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; - assert!(matches!(invoice_status(node2_addr, &invoice).await, InvoiceStatus::Pending)); + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Pending + )); expect_api_ok( wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, "wait for claimable entry to appear", @@ -459,7 +468,10 @@ async fn expire_hodl_invoice() { let payee_payment = wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Failed).await; assert_eq!(payee_payment.status, HTLCStatus::Failed); - assert!(matches!(invoice_status(node2_addr, &invoice).await, InvoiceStatus::Failed)); + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Failed + )); expect_api_ok( wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, false).await, "wait for claimable entry to be removed", @@ -527,7 +539,10 @@ async fn expire_hodl_invoice_by_blocks() { let payee_payment = wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Failed).await; assert_eq!(payee_payment.status, HTLCStatus::Failed); - assert!(matches!(invoice_status(node2_addr, &invoice).await, InvoiceStatus::Failed)); + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Failed + )); expect_api_ok( wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, false).await, "wait for claimable entry to be removed", @@ -589,7 +604,10 @@ async fn reject_wrong_preimage_settle() { .await; // Assert: invoice stays pending and claimable entry remains. - assert!(matches!(invoice_status(node2_addr, &invoice).await, InvoiceStatus::Pending)); + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Pending + )); expect_api_ok( wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, true).await, "wait for claimable entry to remain", @@ -597,9 +615,11 @@ async fn reject_wrong_preimage_settle() { // Now settle with the correct preimage; should succeed and clean up. invoice_settle(node2_addr, payment_hash_hex.clone(), good_preimage_hex).await; - let _ = - wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; - assert!(matches!(invoice_status(node2_addr, &invoice).await, InvoiceStatus::Succeeded)); + let _ = wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Succeeded + )); } #[serial_test::serial] @@ -629,7 +649,10 @@ async fn reject_duplicate_hodl_payment_hash() { .await; // Assert: the original invoice remains pending. - assert!(matches!(invoice_status(node1_addr, &invoice).await, InvoiceStatus::Pending)); + assert!(matches!( + invoice_status(node1_addr, &invoice).await, + InvoiceStatus::Pending + )); } #[serial_test::serial] @@ -654,5 +677,8 @@ async fn auto_claim_invoice_regression() { let payee_payment = wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; assert_eq!(payee_payment.status, HTLCStatus::Succeeded); - assert!(matches!(invoice_status(node2_addr, &invoice).await, InvoiceStatus::Succeeded)); + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Succeeded + )); } diff --git a/src/test/mod.rs b/src/test/mod.rs index 7df6473a..3e814658 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -571,7 +571,11 @@ async fn invoice_hodl( .send() .await .unwrap(); - _check_response_is_ok(res).await.json::().await.unwrap() + _check_response_is_ok(res) + .await + .json::() + .await + .unwrap() } async fn invoice_settle(node_address: SocketAddr, payment_hash: String, payment_preimage: String) { diff --git a/src/utils.rs b/src/utils.rs index 878eb380..ca0fb425 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,6 @@ use amplify::s; +use bitcoin::hashes::sha256::Hash as Sha256; +use bitcoin::hashes::Hash; use bitcoin::io; use bitcoin::secp256k1::PublicKey; use futures::Future; @@ -8,8 +10,6 @@ use lightning::routing::router::{ Payee, PaymentParameters, Route, RouteHint, RouteParameters, Router as _, DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA, MAX_PATH_LENGTH_ESTIMATE, }; -use bitcoin::hashes::sha256::Hash as Sha256; -use bitcoin::hashes::Hash; use lightning::{ ln::{PaymentHash, PaymentPreimage}, onion_message::packet::OnionMessageContents, @@ -485,8 +485,8 @@ pub(crate) fn validate_and_parse_payment_preimage( payment_preimage_str: &str, payment_hash: &PaymentHash, ) -> Result { - let preimage_vec = hex_str_to_vec(payment_preimage_str) - .ok_or_else(|| APIError::InvalidPaymentPreimage)?; + let preimage_vec = + hex_str_to_vec(payment_preimage_str).ok_or_else(|| APIError::InvalidPaymentPreimage)?; if preimage_vec.len() != 32 { return Err(APIError::InvalidPaymentPreimage); } From df71e109be51e91d23b56c5416a2ae961a3bd6e9 Mon Sep 17 00:00:00 2001 From: dcorral Date: Mon, 12 Jan 2026 12:17:45 +0100 Subject: [PATCH 15/17] Add sea-orm and basic db connection flow --- .github/workflows/build.yaml | 1 + .github/workflows/format.yaml | 1 + .github/workflows/lint.yaml | 1 + .github/workflows/test.yaml | 1 + Cargo.lock | 268 +++++++++++++++++++++++++++++++- Cargo.toml | 3 + src/args.rs | 29 ++++ src/error.rs | 6 + src/test/database_connection.rs | 93 +++++++++++ src/test/mod.rs | 4 + src/utils.rs | 32 +++- 11 files changed, 434 insertions(+), 5 deletions(-) create mode 100644 src/test/database_connection.rs diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 21da6348..a802c0c2 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -5,6 +5,7 @@ on: branches: [ "master" ] pull_request: branches: [ "master" ] + workflow_dispatch: env: CARGO_TERM_COLOR: always diff --git a/.github/workflows/format.yaml b/.github/workflows/format.yaml index 7f11eedf..a67be11a 100644 --- a/.github/workflows/format.yaml +++ b/.github/workflows/format.yaml @@ -3,6 +3,7 @@ name: Format code on: push: pull_request: + workflow_dispatch: env: CARGO_TERM_COLOR: always diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 2282f9d2..621b348f 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -5,6 +5,7 @@ on: branches: [ "master" ] pull_request: branches: [ "master" ] + workflow_dispatch: env: CARGO_TERM_COLOR: always diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index fec6aff2..8755bb67 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -5,6 +5,7 @@ on: branches: [ "master" ] pull_request: branches: [ "master" ] + workflow_dispatch: env: CARGO_TERM_COLOR: always diff --git a/Cargo.lock b/Cargo.lock index 1108567c..1316329c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,17 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "version_check", +] + [[package]] name = "ahash" version = "0.8.12" @@ -961,6 +972,18 @@ dependencies = [ "serde", ] +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "blake3" version = "1.8.2" @@ -1020,6 +1043,29 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eeab4423108c5d7c744f4d234de88d18d636100093ae04caf4825134b9c3a32" +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "bp-consensus" version = "0.11.1-alpha.2+unreviewed" @@ -1174,6 +1220,28 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -2046,6 +2114,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.31" @@ -2262,6 +2336,9 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] [[package]] name = "hashbrown" @@ -2269,7 +2346,7 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "ahash", + "ahash 0.8.12", "serde", ] @@ -3603,6 +3680,15 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.4", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -3697,6 +3783,26 @@ dependencies = [ "strict_encoding", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "quinn" version = "0.11.8" @@ -3767,6 +3873,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -3910,6 +4022,15 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + [[package]] name = "reqwest" version = "0.12.22" @@ -4103,9 +4224,12 @@ dependencies = [ "reqwest", "rgb-lib", "scrypt", + "sea-orm", + "sea-orm-migration", "serde", "serde_json", "serial_test", + "sqlx", "tempfile", "thiserror 2.0.12", "time", @@ -4203,6 +4327,35 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "rsa" version = "0.9.8" @@ -4230,8 +4383,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b203a6425500a03e0919c42d3c47caca51e79f1132046626d2c8871c5092035d" dependencies = [ "arrayvec", + "borsh", + "bytes", "num-traits", + "rand 0.8.5", + "rkyv", "serde", + "serde_json", ] [[package]] @@ -4563,10 +4721,15 @@ version = "0.32.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64c91783d1514b99754fc6a4079081dcc2c587dadbff65c48c7f62297443536a" dependencies = [ + "bigdecimal", + "chrono", "inherent", "ordered-float", + "rust_decimal", "sea-query-derive", "serde_json", + "time", + "uuid", ] [[package]] @@ -4575,9 +4738,14 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608" dependencies = [ + "bigdecimal", + "chrono", + "rust_decimal", "sea-query", "serde_json", "sqlx", + "time", + "uuid", ] [[package]] @@ -4619,6 +4787,12 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + [[package]] name = "sec1" version = "0.7.3" @@ -4919,6 +5093,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "single_use_seals" version = "0.11.1-alpha.2" @@ -5054,7 +5234,9 @@ dependencies = [ "async-io 1.13.0", "async-std", "base64 0.22.1", + "bigdecimal", "bytes", + "chrono", "crc", "crossbeam-queue", "either", @@ -5070,14 +5252,19 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", + "rust_decimal", "rustls 0.23.29", "serde", "serde_json", "sha2 0.10.9", "smallvec", "thiserror 2.0.12", + "time", + "tokio", + "tokio-stream", "tracing", "url", + "uuid", "webpki-roots 0.26.11", ] @@ -5116,6 +5303,7 @@ dependencies = [ "sqlx-postgres", "sqlx-sqlite", "syn 2.0.104", + "tokio", "url", ] @@ -5127,9 +5315,11 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", + "bigdecimal", "bitflags 2.9.1", "byteorder", "bytes", + "chrono", "crc", "digest 0.10.7", "dotenvy", @@ -5150,6 +5340,7 @@ dependencies = [ "percent-encoding", "rand 0.8.5", "rsa", + "rust_decimal", "serde", "sha1", "sha2 0.10.9", @@ -5157,7 +5348,9 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror 2.0.12", + "time", "tracing", + "uuid", "whoami", ] @@ -5169,8 +5362,10 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", + "bigdecimal", "bitflags 2.9.1", "byteorder", + "chrono", "crc", "dotenvy", "etcetera", @@ -5185,8 +5380,10 @@ dependencies = [ "log", "md-5", "memchr", + "num-bigint", "once_cell", "rand 0.8.5", + "rust_decimal", "serde", "serde_json", "sha2 0.10.9", @@ -5194,7 +5391,9 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror 2.0.12", + "time", "tracing", + "uuid", "whoami", ] @@ -5205,6 +5404,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", + "chrono", "flume", "futures-channel", "futures-core", @@ -5218,8 +5418,10 @@ dependencies = [ "serde_urlencoded", "sqlx-core", "thiserror 2.0.12", + "time", "tracing", "url", + "uuid", ] [[package]] @@ -5366,6 +5568,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tempfile" version = "3.20.0" @@ -5554,6 +5762,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.15" @@ -5575,8 +5794,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -5588,6 +5807,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +dependencies = [ + "serde", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -5597,11 +5825,32 @@ dependencies = [ "indexmap 2.10.0", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", "winnow", ] +[[package]] +name = "toml_edit" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7211ff1b8f0d3adae1663b7da9ffe396eabe1ca25f0b0bee42b0da29a9ddce93" +dependencies = [ + "indexmap 2.10.0", + "toml_datetime 0.7.0", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + [[package]] name = "toml_write" version = "0.1.2" @@ -5848,7 +6097,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" dependencies = [ "getrandom 0.3.3", + "js-sys", "serde", + "wasm-bindgen", ] [[package]] @@ -6417,6 +6668,15 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index 1a7f8be8..f18b1c39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,8 +39,11 @@ rgb-lib = { version = "0.3.0-beta.4", features = [ "esplora", ] } scrypt = "0.11.0" +sea-orm = { version = "1.1", features = ["sqlx-sqlite", "sqlx-mysql", "sqlx-postgres", "runtime-tokio-rustls", "macros"] } +sea-orm-migration = "1.1" serde = { version = "^1.0", features = ["derive"] } serde_json = "1.0" +sqlx = { version = "0.8", features = ["sqlite", "mysql", "postgres", "runtime-tokio-rustls"] } tempfile = "3.14.0" thiserror = "2.0" time = { version = "0.3.36", features = ["std"] } diff --git a/src/args.rs b/src/args.rs index 496bbbeb..d70f5bb3 100644 --- a/src/args.rs +++ b/src/args.rs @@ -6,6 +6,23 @@ use crate::auth::check_auth_args; use crate::error::AppError; use crate::utils::check_port_is_available; +#[derive(clap::ValueEnum, Clone, Debug)] +pub(crate) enum DatabaseType { + Sqlite, + Mysql, + Postgresql, +} + +impl std::fmt::Display for DatabaseType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DatabaseType::Sqlite => write!(f, "sqlite"), + DatabaseType::Mysql => write!(f, "mysql"), + DatabaseType::Postgresql => write!(f, "postgresql"), + } + } +} + #[derive(Parser)] #[command(author, version, about, long_about = None)] struct Args { @@ -35,6 +52,14 @@ struct Args { /// Disable authentication #[arg(long, default_value_t = false)] disable_authentication: bool, + + /// Database type: sqlite, mysql, postgresql + #[arg(long, default_value_t = DatabaseType::Sqlite)] + database_type: DatabaseType, + + /// Database URL (required for mysql/postgresql) + #[arg(long)] + database_url: Option, } pub(crate) struct UserArgs { @@ -44,6 +69,8 @@ pub(crate) struct UserArgs { pub(crate) network: BitcoinNetwork, pub(crate) max_media_upload_size_mb: u16, pub(crate) root_public_key: Option, + pub(crate) database_type: DatabaseType, + pub(crate) database_url: Option, } pub(crate) fn parse_startup_args() -> Result { @@ -65,5 +92,7 @@ pub(crate) fn parse_startup_args() -> Result { network, max_media_upload_size_mb: args.max_media_upload_size_mb, root_public_key, + database_type: args.database_type, + database_url: args.database_url, }) } diff --git a/src/error.rs b/src/error.rs index 5cfd80a6..08d6ac25 100644 --- a/src/error.rs +++ b/src/error.rs @@ -551,6 +551,9 @@ impl IntoResponse for APIError { /// The error variants returned by the app #[derive(Debug, thiserror::Error)] pub enum AppError { + #[error("Configuration error: {0}")] + ConfigError(String), + #[error("The provided authentication args are invalid")] InvalidAuthenticationArgs, @@ -565,4 +568,7 @@ pub enum AppError { #[error("Port {0} is unavailable")] UnavailablePort(u16), + + #[error("Database connection error: {0}")] + DatabaseConnection(String), } diff --git a/src/test/database_connection.rs b/src/test/database_connection.rs new file mode 100644 index 00000000..1bb985f3 --- /dev/null +++ b/src/test/database_connection.rs @@ -0,0 +1,93 @@ +use super::*; +use crate::args::DatabaseType; +use tempfile::TempDir; +use tracing_test::traced_test; + +#[traced_test] +#[tokio::test] +async fn test_database_connection_sqlite() { + let temp_dir = TempDir::new().unwrap(); + let args = UserArgs { + storage_dir_path: temp_dir.path().to_path_buf(), + daemon_listening_port: 3001, + ldk_peer_listening_port: 9735, + network: rgb_lib::BitcoinNetwork::Testnet, + max_media_upload_size_mb: 5, + root_public_key: None, + database_type: DatabaseType::Sqlite, + database_url: None, + }; + + let app_state = crate::utils::start_daemon(&args).await.unwrap(); + + // test ping + app_state.static_state.db.ping().await.unwrap(); + + // check db file created + let db_path = temp_dir.path().join("db.sqlite"); + assert!(db_path.exists(), "SQLite database file should be created"); +} + +#[tokio::test] +async fn test_database_connection_invalid_mysql() { + let temp_dir = TempDir::new().unwrap(); + let args = UserArgs { + storage_dir_path: temp_dir.path().to_path_buf(), + daemon_listening_port: 3001, + ldk_peer_listening_port: 9735, + network: rgb_lib::BitcoinNetwork::Testnet, + max_media_upload_size_mb: 5, + root_public_key: None, + database_type: DatabaseType::Mysql, + database_url: None, // missing url should cause error + }; + + let result = crate::utils::start_daemon(&args).await; + assert!( + result.is_err(), + "Should fail without database URL for MySQL" + ); + if let Err(err) = result { + match err { + crate::error::AppError::ConfigError(msg) => { + assert!( + msg.contains("Database URL required"), + "Error should mention missing URL" + ); + } + _ => panic!("Expected ConfigError, got {:?}", err), + } + } +} + +#[tokio::test] +async fn test_database_connection_invalid_postgresql() { + let temp_dir = TempDir::new().unwrap(); + let args = UserArgs { + storage_dir_path: temp_dir.path().to_path_buf(), + daemon_listening_port: 3001, + ldk_peer_listening_port: 9735, + network: rgb_lib::BitcoinNetwork::Testnet, + max_media_upload_size_mb: 5, + root_public_key: None, + database_type: DatabaseType::Postgresql, + database_url: None, // missing url should cause error + }; + + let result = crate::utils::start_daemon(&args).await; + assert!( + result.is_err(), + "Should fail without database URL for PostgreSQL" + ); + if let Err(err) = result { + match err { + crate::error::AppError::ConfigError(msg) => { + assert!( + msg.contains("Database URL required"), + "Error should mention missing URL" + ); + } + _ => panic!("Expected ConfigError, got {:?}", err), + } + } +} diff --git a/src/test/mod.rs b/src/test/mod.rs index 3e814658..ff5c8147 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -17,6 +17,7 @@ use tokio::io::AsyncReadExt; use tokio::net::TcpListener; use tracing_test::traced_test; +use crate::args::DatabaseType; use crate::error::APIErrorResponse; use crate::ldk::FEE_RATE; use crate::routes::{ @@ -67,6 +68,8 @@ impl Default for UserArgs { ldk_peer_listening_port: 9735, max_media_upload_size_mb: 3, root_public_key: None, + database_type: DatabaseType::Sqlite, + database_url: None, } } } @@ -1860,6 +1863,7 @@ mod close_force_other_side; mod close_force_standard; mod concurrent_btc_payments; mod concurrent_openchannel; +mod database_connection; mod fail_transfers; mod getchannelid; mod hodl_invoice; diff --git a/src/utils.rs b/src/utils.rs index ca0fb425..f0cd40a4 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -19,6 +19,7 @@ use lightning::{ use lightning_persister::fs_store::FilesystemStore; use magic_crypt::{new_magic_crypt, MagicCryptTrait}; use rgb_lib::{bdk_wallet::keys::bip39::Mnemonic, BitcoinNetwork, ContractId}; +use sea_orm::DatabaseConnection; use std::{ collections::HashSet, fmt::Write, @@ -37,7 +38,7 @@ use crate::ldk::{ChannelIdsMap, Router}; use crate::rgb::{get_rgb_channel_info_optional, RgbLibWalletWrapper}; use crate::routes::{DEFAULT_FINAL_CLTV_EXPIRY_DELTA, HTLC_MIN_MSAT}; use crate::{ - args::UserArgs, + args::{DatabaseType, UserArgs}, disk::FilesystemLogger, error::{APIError, AppError}, ldk::{ @@ -93,6 +94,7 @@ pub(crate) struct StaticState { pub(crate) ldk_data_dir: PathBuf, pub(crate) logger: Arc, pub(crate) max_media_upload_size_mb: u16, + pub(crate) db: DatabaseConnection, } pub(crate) struct UnlockedAppState { @@ -358,6 +360,24 @@ pub(crate) fn parse_peer_info( Ok((pubkey.unwrap(), peer_addr)) } +fn get_database_url( + db_type: &DatabaseType, + db_url: Option<&str>, + storage_dir: &Path, +) -> Result { + match db_type { + DatabaseType::Sqlite => { + let db_path = storage_dir.join("db.sqlite"); + Ok(format!("sqlite://{}?mode=rwc", db_path.display())) + } + DatabaseType::Mysql | DatabaseType::Postgresql => db_url + .ok_or(AppError::ConfigError( + "Database URL required for mysql/postgresql".to_string(), + )) + .map(|s| s.to_string()), + } +} + pub(crate) async fn start_daemon(args: &UserArgs) -> Result, AppError> { // Initialize the Logger (creates ldk_data_dir and its logs directory) let ldk_data_dir = args.storage_dir_path.join(LDK_DIR); @@ -365,6 +385,15 @@ pub(crate) async fn start_daemon(args: &UserArgs) -> Result, AppEr let cancel_token = CancellationToken::new(); + let database_url = get_database_url( + &args.database_type, + args.database_url.as_deref(), + &args.storage_dir_path, + )?; + let db = sea_orm::Database::connect(&database_url) + .await + .map_err(|e| AppError::DatabaseConnection(format!("Failed to connect: {}", e)))?; + let static_state = Arc::new(StaticState { ldk_peer_listening_port: args.ldk_peer_listening_port, network: args.network, @@ -372,6 +401,7 @@ pub(crate) async fn start_daemon(args: &UserArgs) -> Result, AppEr ldk_data_dir, logger, max_media_upload_size_mb: args.max_media_upload_size_mb, + db, }); let app_state = Arc::new(AppState { From 9c92a0e383f11b8cd0870377379c9b60234bcb25 Mon Sep 17 00:00:00 2001 From: dcorral Date: Mon, 12 Jan 2026 19:12:20 +0100 Subject: [PATCH 16/17] Init/Lock/Unlock working with sqlite and sea-orm --- src/error.rs | 4 +++ src/ldk.rs | 18 +++++++---- src/routes.rs | 47 ++++++++++++++--------------- src/utils.rs | 82 ++++++++++++++++++++++++++++++++++++--------------- 4 files changed, 98 insertions(+), 53 deletions(-) diff --git a/src/error.rs b/src/error.rs index 08d6ac25..ba69af51 100644 --- a/src/error.rs +++ b/src/error.rs @@ -75,6 +75,9 @@ pub enum APIError { #[error("Unable to create keys seed file {0}: {1}")] FailedKeysCreation(String, String), + #[error("Database error: {0}")] + DatabaseError(String), + #[error("Failed to open channel: {0}")] FailedOpenChannel(String), @@ -426,6 +429,7 @@ impl IntoResponse for APIError { | APIError::FailedPayment(_) | APIError::FailedPeerDisconnection(_) | APIError::FailedSendingOnionMessage(_) + | APIError::DatabaseError(_) | APIError::IO(_) | APIError::Unexpected(_) => ( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/src/ldk.rs b/src/ldk.rs index 56415b5b..124ff2ad 100644 --- a/src/ldk.rs +++ b/src/ldk.rs @@ -58,7 +58,7 @@ use rgb_lib::{ utils::{get_account_data, recipient_id_from_script_buf, script_buf_from_recipient_id}, wallet::{ rust_only::{check_indexer_url, AssetColoringInfo, ColoringInfo}, - DatabaseType, Recipient, TransportEndpoint, Wallet as RgbLibWallet, WalletData, + DatabaseType, Online, Recipient, TransportEndpoint, Wallet as RgbLibWallet, WalletData, WitnessData, }, AssetSchema, Assignment, BitcoinNetwork, ConsignmentExt, ContractId, FileContent, RgbTransfer, @@ -2079,8 +2079,9 @@ pub(crate) async fn start_ldk( .clone() .to_string_lossy() .to_string(); - let mut rgb_wallet = tokio::task::spawn_blocking(move || { - RgbLibWallet::new(WalletData { + let indexer_url_str = indexer_url.to_string(); + let (rgb_wallet, rgb_online) = tokio::task::spawn_blocking(move || { + let mut wallet = RgbLibWallet::new(WalletData { data_dir, bitcoin_network, database_type: DatabaseType::Sqlite, @@ -2092,11 +2093,16 @@ pub(crate) async fn start_ldk( vanilla_keychain: None, supported_schemas: vec![AssetSchema::Nia, AssetSchema::Cfa, AssetSchema::Uda], }) - .expect("valid rgb-lib wallet") + .expect("valid rgb-lib wallet"); + + let online = wallet.go_online(false, indexer_url_str)?; + + Ok::<(RgbLibWallet, Online), rgb_lib::Error>((wallet, online)) }) .await - .unwrap(); - let rgb_online = rgb_wallet.go_online(false, indexer_url.to_string())?; + .unwrap() + .map_err(|e| APIError::Unexpected(format!("Unmapped rgb-lib error: {:?}", e)))?; + fs::write( static_state.storage_dir_path.join(WALLET_FINGERPRINT_FNAME), account_xpub_colored.fingerprint().to_string(), diff --git a/src/routes.rs b/src/routes.rs index df5ab9de..37471497 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -84,7 +84,7 @@ use crate::ldk::{start_ldk, stop_ldk, LdkBackgroundServices, MIN_CHANNEL_CONFIRM use crate::swap::{SwapData, SwapInfo, SwapString}; use crate::utils::{ check_already_initialized, check_channel_id, check_password_strength, check_password_validity, - encrypt_and_save_mnemonic, get_max_local_rgb_amount, get_mnemonic_path, get_route, hex_str, + encrypt_and_save_mnemonic, get_max_local_rgb_amount, get_route, hex_str, hex_str_to_compressed_pubkey, hex_str_to_vec, validate_and_parse_payment_hash, validate_and_parse_payment_preimage, UnlockedAppState, UserOnionMessageContents, }; @@ -1377,8 +1377,7 @@ pub(crate) async fn backup( no_cancel(async move { let _guard = state.check_locked().await?; - let _mnemonic = - check_password_validity(&payload.password, &state.static_state.storage_dir_path)?; + let _mnemonic = check_password_validity(&payload.password, &state.static_state.db).await?; do_backup( &state.static_state.storage_dir_path, @@ -1425,13 +1424,14 @@ pub(crate) async fn change_password( check_password_strength(payload.new_password.clone())?; let mnemonic = - check_password_validity(&payload.old_password, &state.static_state.storage_dir_path)?; + check_password_validity(&payload.old_password, &state.static_state.db).await?; encrypt_and_save_mnemonic( payload.new_password, mnemonic.to_string(), - &get_mnemonic_path(&state.static_state.storage_dir_path), - )?; + &state.static_state.db, + ) + .await?; Ok(Json(EmptyResponse {})) }) @@ -1761,14 +1761,14 @@ pub(crate) async fn init( check_password_strength(payload.password.clone())?; - let mnemonic_path = get_mnemonic_path(&state.static_state.storage_dir_path); - check_already_initialized(&mnemonic_path)?; + check_already_initialized(&state.static_state.db).await?; let keys = generate_keys(state.static_state.network); let mnemonic = keys.mnemonic; - encrypt_and_save_mnemonic(payload.password, mnemonic.clone(), &mnemonic_path)?; + encrypt_and_save_mnemonic(payload.password, mnemonic.clone(), &state.static_state.db) + .await?; Ok(Json(InitResponse { mnemonic })) }) @@ -2823,6 +2823,11 @@ pub(crate) async fn lock( state.update_changing_state(true); drop(unlocked_state); } + Err(APIError::LockedNode) => { + // Node is already locked, which is the desired state + tracing::info!("Node is already locked"); + return Ok(Json(EmptyResponse {})); + } Err(e) => { state.update_changing_state(false); return Err(e); @@ -3532,8 +3537,7 @@ pub(crate) async fn restore( no_cancel(async move { let _unlocked_state = state.check_locked().await?; - let mnemonic_path = get_mnemonic_path(&state.static_state.storage_dir_path); - check_already_initialized(&mnemonic_path)?; + check_already_initialized(&state.static_state.db).await?; restore_backup( Path::new(&payload.backup_path), @@ -3541,8 +3545,7 @@ pub(crate) async fn restore( &state.static_state.storage_dir_path, )?; - let _mnemonic = - check_password_validity(&payload.password, &state.static_state.storage_dir_path)?; + let _mnemonic = check_password_validity(&payload.password, &state.static_state.db).await?; Ok(Json(EmptyResponse {})) }) @@ -4005,16 +4008,14 @@ pub(crate) async fn unlock( } } - let mnemonic = match check_password_validity( - &payload.password, - &state.static_state.storage_dir_path, - ) { - Ok(mnemonic) => mnemonic, - Err(e) => { - state.update_changing_state(false); - return Err(e); - } - }; + let mnemonic = + match check_password_validity(&payload.password, &state.static_state.db).await { + Ok(mnemonic) => mnemonic, + Err(e) => { + state.update_changing_state(false); + return Err(e); + } + }; tracing::debug!("Starting LDK..."); let (new_ldk_background_services, new_unlocked_app_state) = diff --git a/src/utils.rs b/src/utils.rs index f0cd40a4..03a8615f 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -19,11 +19,10 @@ use lightning::{ use lightning_persister::fs_store::FilesystemStore; use magic_crypt::{new_magic_crypt, MagicCryptTrait}; use rgb_lib::{bdk_wallet::keys::bip39::Mnemonic, BitcoinNetwork, ContractId}; -use sea_orm::DatabaseConnection; +use sea_orm::{ConnectionTrait, DatabaseConnection, Statement}; use std::{ collections::HashSet, fmt::Write, - fs, net::{SocketAddr, TcpStream, ToSocketAddrs}, path::Path, path::PathBuf, @@ -171,8 +170,24 @@ impl Writeable for UserOnionMessageContents { } } -pub(crate) fn check_already_initialized(mnemonic_path: &Path) -> Result<(), APIError> { - if mnemonic_path.exists() { +pub(crate) async fn check_already_initialized(db: &DatabaseConnection) -> Result<(), APIError> { + let create_table_stmt = Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "CREATE TABLE IF NOT EXISTS mnemonic (id INTEGER PRIMARY KEY, encrypted_mnemonic TEXT NOT NULL)".to_string(), + ); + db.execute(create_table_stmt) + .await + .map_err(|e| APIError::DatabaseError(format!("Failed to create mnemonic table: {}", e)))?; + + let stmt = Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "SELECT id FROM mnemonic WHERE id = 1".to_string(), + ); + let result = db.query_one(stmt).await.map_err(|e| { + APIError::DatabaseError(format!("Failed to check mnemonic existence: {}", e)) + })?; + + if result.is_some() { return Err(APIError::AlreadyInitialized); } Ok(()) @@ -187,12 +202,31 @@ pub(crate) fn check_password_strength(password: String) -> Result<(), APIError> Ok(()) } -pub(crate) fn check_password_validity( +pub(crate) async fn check_password_validity( password: &str, - storage_dir_path: &Path, + db: &DatabaseConnection, ) -> Result { - let mnemonic_path = get_mnemonic_path(storage_dir_path); - if let Ok(encrypted_mnemonic) = fs::read_to_string(mnemonic_path) { + let create_table_stmt = Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "CREATE TABLE IF NOT EXISTS mnemonic (id INTEGER PRIMARY KEY, encrypted_mnemonic TEXT NOT NULL)".to_string(), + ); + db.execute(create_table_stmt) + .await + .map_err(|e| APIError::DatabaseError(format!("Failed to create mnemonic table: {}", e)))?; + + let stmt = Statement::from_string( + sea_orm::DatabaseBackend::Sqlite, + "SELECT encrypted_mnemonic FROM mnemonic WHERE id = 1".to_string(), + ); + let result = db + .query_one(stmt) + .await + .map_err(|e| APIError::DatabaseError(format!("Failed to read mnemonic: {}", e)))?; + + if let Some(row) = result { + let encrypted_mnemonic: String = row.try_get_by_index(0).map_err(|e| { + APIError::DatabaseError(format!("Failed to get mnemonic from row: {}", e)) + })?; let mcrypt = new_magic_crypt!(password, 256); let mnemonic_str = mcrypt .decrypt_base64_to_string(encrypted_mnemonic) @@ -221,27 +255,27 @@ pub(crate) fn check_port_is_available(port: u16) -> Result<(), AppError> { Ok(()) } -pub(crate) fn get_mnemonic_path(storage_dir_path: &Path) -> PathBuf { - storage_dir_path.join("mnemonic") -} - -pub(crate) fn encrypt_and_save_mnemonic( +pub(crate) async fn encrypt_and_save_mnemonic( password: String, mnemonic: String, - mnemonic_path: &Path, + db: &DatabaseConnection, ) -> Result<(), APIError> { let mcrypt = new_magic_crypt!(password, 256); let encrypted_mnemonic = mcrypt.encrypt_str_to_base64(mnemonic); - match fs::write(mnemonic_path, encrypted_mnemonic) { - Ok(()) => { - tracing::info!("Created a new wallet"); - Ok(()) - } - Err(e) => Err(APIError::FailedKeysCreation( - mnemonic_path.to_string_lossy().to_string(), - e.to_string(), - )), - } + + let create_table_stmt = Statement::from_string(sea_orm::DatabaseBackend::Sqlite, "CREATE TABLE IF NOT EXISTS mnemonic (id INTEGER PRIMARY KEY, encrypted_mnemonic TEXT NOT NULL)".to_string()); + db.execute(create_table_stmt) + .await + .map_err(|e| APIError::DatabaseError(format!("Failed to create mnemonic table: {}", e)))?; + + let sql = format!("INSERT INTO mnemonic (id, encrypted_mnemonic) VALUES (1, '{}') ON CONFLICT(id) DO UPDATE SET encrypted_mnemonic = excluded.encrypted_mnemonic", encrypted_mnemonic.replace("'", "''")); + let stmt = Statement::from_string(sea_orm::DatabaseBackend::Sqlite, sql); + db.execute(stmt) + .await + .map_err(|e| APIError::FailedKeysCreation("database".to_string(), e.to_string()))?; + + tracing::info!("Created a new wallet"); + Ok(()) } pub(crate) async fn connect_peer_if_necessary( From c9673fd95329287fcd80dcf437ae9e5402870d83 Mon Sep 17 00:00:00 2001 From: dcorral Date: Thu, 15 Jan 2026 12:18:41 +0100 Subject: [PATCH 17/17] Add tests --- src/test/database_lock_unlock.rs | 103 +++++++++++++++++++++++++++++++ src/test/mod.rs | 1 + 2 files changed, 104 insertions(+) create mode 100644 src/test/database_lock_unlock.rs diff --git a/src/test/database_lock_unlock.rs b/src/test/database_lock_unlock.rs new file mode 100644 index 00000000..2d292634 --- /dev/null +++ b/src/test/database_lock_unlock.rs @@ -0,0 +1,103 @@ +use super::*; +use crate::error::APIError; +use crate::utils::{check_already_initialized, check_password_validity, encrypt_and_save_mnemonic}; +use sea_orm::{Database, DatabaseConnection}; +use tempfile::TempDir; + +#[traced_test] +#[tokio::test] +async fn test_encrypt_and_save_mnemonic() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let db_url = format!("sqlite://{}?mode=rwc", db_path.to_string_lossy()); + let db: DatabaseConnection = Database::connect(&db_url).await.unwrap(); + + let password = "test_password_123"; + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + let result = encrypt_and_save_mnemonic(password.to_string(), mnemonic.to_string(), &db).await; + assert!( + result.is_ok(), + "Failed to encrypt and save mnemonic: {:?}", + result + ); + + let retrieved_mnemonic = check_password_validity(password, &db).await; + assert!( + retrieved_mnemonic.is_ok(), + "Failed to retrieve mnemonic: {:?}", + retrieved_mnemonic + ); + assert_eq!(retrieved_mnemonic.unwrap().to_string(), mnemonic); +} + +#[traced_test] +#[tokio::test] +async fn test_check_password_validity_wrong_password() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let db_url = format!("sqlite://{}?mode=rwc", db_path.to_string_lossy()); + let db: DatabaseConnection = Database::connect(&db_url).await.unwrap(); + + let password = "correct_password"; + let wrong_password = "wrong_password"; + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + + encrypt_and_save_mnemonic(password.to_string(), mnemonic.to_string(), &db) + .await + .unwrap(); + + let result = check_password_validity(wrong_password, &db).await; + assert!( + matches!(result, Err(APIError::WrongPassword)), + "Expected WrongPassword error, got {:?}", + result + ); +} + +#[traced_test] +#[tokio::test] +async fn test_check_password_validity_uninitialized() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let db_url = format!("sqlite://{}?mode=rwc", db_path.to_string_lossy()); + let db: DatabaseConnection = Database::connect(&db_url).await.unwrap(); + + let password = "some_password"; + + let result = check_password_validity(password, &db).await; + assert!( + matches!(result, Err(APIError::NotInitialized)), + "Expected NotInitialized error, got {:?}", + result + ); +} + +#[traced_test] +#[tokio::test] +async fn test_check_already_initialized() { + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test.db"); + let db_url = format!("sqlite://{}?mode=rwc", db_path.to_string_lossy()); + let db: DatabaseConnection = Database::connect(&db_url).await.unwrap(); + + let result = check_already_initialized(&db).await; + assert!( + result.is_ok(), + "Expected OK for uninitialized, got {:?}", + result + ); + + let password = "test_password"; + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + encrypt_and_save_mnemonic(password.to_string(), mnemonic.to_string(), &db) + .await + .unwrap(); + + let result = check_already_initialized(&db).await; + assert!( + matches!(result, Err(APIError::AlreadyInitialized)), + "Expected AlreadyInitialized error, got {:?}", + result + ); +} diff --git a/src/test/mod.rs b/src/test/mod.rs index ff5c8147..9a549863 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -1864,6 +1864,7 @@ mod close_force_standard; mod concurrent_btc_payments; mod concurrent_openchannel; mod database_connection; +mod database_lock_unlock; mod fail_transfers; mod getchannelid; mod hodl_invoice;