diff --git a/README.md b/README.md index 2606512..23482c2 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,7 @@ The node currently exposes the following APIs: - `/assetmetadata` (POST) - `/backup` (POST) - `/btcbalance` (POST) +- `/cancelhodlinvoice` (POST) - `/changepassword` (POST) - `/checkindexerurl` (POST) - `/checkproxyendpoint` (POST) @@ -217,7 +218,9 @@ The node currently exposes the following APIs: - `/getassetmedia` (POST) - `/getchannelid` (POST) - `/getpayment` (POST) +- `/getpaymentpreimage` (POST) - `/getswap` (POST) +- `/hodlinvoice` (POST) - `/init` (POST) - `/invoicestatus` (POST) - `/issueassetcfa` (POST) @@ -248,6 +251,7 @@ The node currently exposes the following APIs: - `/sendbtc` (POST) - `/sendonionmessage` (POST) - `/sendpayment` (POST) +- `/settlehodlinvoice` (POST) - `/shutdown` (POST) - `/signmessage` (POST) - `/sync` (POST) diff --git a/openapi.yaml b/openapi.yaml index cda48b8..9669268 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -115,6 +115,24 @@ paths: application/json: schema: $ref: '#/components/schemas/BtcBalanceResponse' + /cancelhodlinvoice: + post: + tags: + - Invoices + summary: Cancel a HODL invoice + description: Cancel a held HTLC for a HODL invoice. Rejects cancellation if a settlement is already in progress. + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/InvoiceCancelRequest' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/EmptyResponse' /changepassword: post: tags: @@ -368,6 +386,24 @@ paths: application/json: schema: $ref: '#/components/schemas/GetPaymentResponse' + /getpaymentpreimage: + post: + tags: + - Payments + summary: Get a payment preimage by its payment hash + description: Get the preimage for an outbound payment when it has been completed successfully + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/GetPaymentPreimageRequest' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/GetPaymentPreimageResponse' /getswap: post: tags: @@ -386,6 +422,24 @@ paths: application/json: schema: $ref: '#/components/schemas/GetSwapResponse' + /hodlinvoice: + 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. Metadata is persisted first to preserve HODL semantics across restarts. + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/InvoiceHodlRequest' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/InvoiceHodlResponse' /init: post: tags: @@ -892,6 +946,24 @@ paths: application/json: schema: $ref: '#/components/schemas/SendPaymentResponse' + /settlehodlinvoice: + 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' /shutdown: post: tags: @@ -1519,6 +1591,23 @@ components: properties: payment: $ref: '#/components/schemas/Payment' + GetPaymentPreimageRequest: + type: object + required: + - payment_hash + properties: + payment_hash: + type: string + example: b4cb2da889477082a2e47f37a07e646e60ef6f97ffa7a4d88c823efd673da94b + GetPaymentPreimageResponse: + type: object + properties: + status: + $ref: '#/components/schemas/HTLCStatus' + preimage: + type: string + nullable: true + example: eade701c7b23b8799465f4284ad84710fc16a776fbc6483001291149122695a8 GetSwapRequest: type: object properties: @@ -1537,7 +1626,9 @@ components: type: string enum: - Pending + - Claimable - Succeeded + - Cancelled - Failed IndexerProtocol: type: string @@ -1556,11 +1647,65 @@ components: mnemonic: type: string example: skill lamp please gown put season degree collect decline account monitor insane + InvoiceCancelRequest: + type: object + required: + - payment_hash + properties: + payment_hash: + type: string + example: 3febfae1e68b190c15461f4c2a3290f9af1dae63fd7d620d2bd61601869026cd + InvoiceHodlRequest: + type: object + required: + - payment_hash + - expiry_sec + 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 + required: + - payment_hash + - payment_preimage + properties: + payment_hash: + type: string + example: b4cb2da889477082a2e47f37a07e646e60ef6f97ffa7a4d88c823efd673da94b + payment_preimage: + type: string + example: eade701c7b23b8799465f4284ad84710fc16a776fbc6483001291149122695a8 InvoiceStatus: type: string enum: - Pending - Succeeded + - Cancelled - Failed - Expired InvoiceStatusRequest: diff --git a/regtest.sh b/regtest.sh index b3452f0..a61f944 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" diff --git a/src/disk.rs b/src/disk.rs index 6c6f104..2ad46fb 100644 --- a/src/disk.rs +++ b/src/disk.rs @@ -15,8 +15,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}; @@ -24,6 +24,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"; @@ -178,6 +180,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: new_hash_map(), + } +} + +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: new_hash_map(), + } +} + 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 52e0477..6dfefc5 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, @@ -210,6 +213,24 @@ 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("Invoice settlement is in progress")] + InvoiceSettlingInProgress, + + #[error("Invoice is already settled")] + InvoiceAlreadySettled, + #[error("IO error: {0}")] IO(#[from] std::io::Error), @@ -234,6 +255,9 @@ pub enum APIError { #[error("Unable to find payment preimage, be sure you've provided the correct swap info")] MissingSwapPaymentPreimage, + #[error("Invalid payment preimage")] + InvalidPaymentPreimage, + #[error("Network error: {0}")] Network(String), @@ -437,13 +461,16 @@ impl IntoResponse for APIError { | APIError::InvalidOnionData(_) | APIError::InvalidPassword(_) | APIError::InvalidPaymentHash(_) + | APIError::PaymentHashAlreadyUsed | APIError::InvalidPaymentSecret + | APIError::InvalidPaymentPreimage | APIError::InvalidPeerInfo(_) | APIError::InvalidPrecision(_) | APIError::InvalidPubkey | APIError::InvalidRecipientData(_) | APIError::InvalidRecipientID | APIError::InvalidRecipientNetwork + | APIError::InvoiceExpired | APIError::InvalidSwap(_) | APIError::InvalidSwapString(_, _) | APIError::InvalidTicker(_) @@ -454,6 +481,7 @@ impl IntoResponse for APIError { | APIError::MediaFileNotProvided | APIError::MissingSwapPaymentPreimage | APIError::OutputBelowDustLimit + | APIError::ClaimDeadlineExceeded | APIError::UnsupportedBackupVersion { .. } => { (StatusCode::BAD_REQUEST, self.to_string(), self.name()) } @@ -499,6 +527,13 @@ impl IntoResponse for APIError { | APIError::UnsupportedTransportType => { (StatusCode::FORBIDDEN, self.to_string(), self.name()) } + APIError::InvoiceNotClaimable => (StatusCode::NOT_FOUND, self.to_string(), self.name()), + APIError::InvoiceAlreadySettled => { + (StatusCode::CONFLICT, self.to_string(), self.name()) + } + APIError::InvoiceNotHodl | APIError::InvoiceSettlingInProgress => { + (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 32608d4..d723105 100644 --- a/src/ldk.rs +++ b/src/ldk.rs @@ -45,7 +45,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, NO_LIQUIDITY_MANAGER}; use lightning_block_sync::gossip::TokioSpawner; use lightning_block_sync::init; @@ -93,8 +93,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}; @@ -116,6 +117,7 @@ pub(crate) struct LdkBackgroundServices { peer_manager: Arc, bp_exit: Sender<()>, background_processor: Option>>, + claimable_expiry_task: Option>, } #[derive(Clone, Debug)] @@ -139,6 +141,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 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, 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, + /// 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, + /// 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, { + (0, payment_hash, required), + (2, amount_msat, required), + (4, invoice_expiry, required), + (6, claim_deadline_height, required), + (8, created_at, required), + (10, settling, option), + (12, settling_since, option), +}); + pub(crate) struct InboundPaymentInfoStorage { pub(crate) payments: LdkHashMap, } @@ -147,6 +212,14 @@ impl_writeable_tlv_based!(InboundPaymentInfoStorage, { (0, payments, required), }); +pub(crate) struct InvoiceMetadataStorage { + pub(crate) invoices: LdkHashMap, +} + +impl_writeable_tlv_based!(InvoiceMetadataStorage, { + (0, invoices, required), +}); + pub(crate) struct OutboundPaymentInfoStorage { pub(crate) payments: LdkHashMap, } @@ -155,6 +228,14 @@ impl_writeable_tlv_based!(OutboundPaymentInfoStorage, { (0, payments, required), }); +pub(crate) struct ClaimablePaymentStorage { + pub(crate) payments: LdkHashMap, +} + +impl_writeable_tlv_based!(ClaimablePaymentStorage, { + (0, payments, required), +}); + pub(crate) struct SwapMap { pub(crate) swaps: LdkHashMap, } @@ -172,6 +253,54 @@ 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)| { + 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) + .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); @@ -246,6 +375,103 @@ impl UnlockedAppState { self.save_inbound_payments(inbound); } + pub(crate) fn add_hodl_invoice_records( + &self, + payment_hash: PaymentHash, + payment_info: PaymentInfo, + metadata: InvoiceMetadata, + ) { + let mut invoices = self.get_invoice_metadata(); + let mut inbound = self.get_inbound_payments(); + invoices.invoices.insert(payment_hash, metadata); + inbound.payments.insert(payment_hash, payment_info); + // Persist metadata first so a crash cannot downgrade HODL to auto-claim. + self.save_invoice_metadata(invoices); + 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) -> LdkHashMap { + 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 + } + + /// 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); + }; + + // Check if settlement is already in progress (atomic check-and-set). + if claimable.settling.unwrap_or(false) { + return Err(APIError::InvoiceSettlingInProgress); + } + + 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() + } + pub(crate) fn add_outbound_payment( &self, payment_id: PaymentId, @@ -297,13 +523,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, @@ -671,7 +909,7 @@ async fn handle_ldk_events( purpose, amount_msat, receiver_node_id: _, - claim_deadline: _, + claim_deadline, onion_fields: _, counterparty_skimmed_fee_msat: _, receiving_channel_ids: _, @@ -682,21 +920,196 @@ 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()); + + // 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 { + // 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!( + "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 claimable = ClaimablePayment { + payment_hash, + amount_msat, + invoice_expiry: metadata.expiry, + claim_deadline_height: claim_deadline, + created_at: now_ts, + settling: Some(false), + settling_since: None, + }; + unlocked_state.upsert_claimable_payment(claimable); + unlocked_state.upsert_inbound_payment( + payment_hash, + HTLCStatus::Claimable, + None, + payment_secret, + Some(amount_msat), + unlocked_state.channel_manager.get_our_node_id(), + ); + } + } } Event::PaymentClaimed { payment_hash, @@ -765,6 +1178,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, @@ -2032,6 +2450,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), ))); @@ -2062,6 +2486,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(), @@ -2101,6 +2527,57 @@ 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 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 + .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(()); let background_processor = tokio::spawn(process_events_async( @@ -2236,6 +2713,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, )) @@ -2260,6 +2738,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/main.rs b/src/main.rs index aaa9887..3cd55a6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,15 +42,16 @@ use crate::auth::conditional_auth_middleware; use crate::error::AppError; use crate::ldk::stop_ldk; 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, + address, asset_balance, asset_metadata, backup, btc_balance, cancel_hodl_invoice, + 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_payment_preimage, get_swap, + hodl_invoice, 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, settle_hodl_invoice, + shutdown, sign_message, sync, taker, unlock, }; use crate::utils::{start_daemon, AppState, LOGS_DIR}; @@ -109,6 +110,7 @@ pub(crate) async fn app(args: UserArgs) -> Result<(Router, Arc), AppEr .route("/assetmetadata", post(asset_metadata)) .route("/backup", post(backup)) .route("/btcbalance", post(btc_balance)) + .route("/cancelhodlinvoice", post(cancel_hodl_invoice)) .route("/changepassword", post(change_password)) .route("/checkindexerurl", post(check_indexer_url)) .route("/checkproxyendpoint", post(check_proxy_endpoint)) @@ -123,7 +125,9 @@ pub(crate) async fn app(args: UserArgs) -> Result<(Router, Arc), AppEr .route("/getassetmedia", post(get_asset_media)) .route("/getchannelid", post(get_channel_id)) .route("/getpayment", post(get_payment)) + .route("/getpaymentpreimage", post(get_payment_preimage)) .route("/getswap", post(get_swap)) + .route("/hodlinvoice", post(hodl_invoice)) .route("/init", post(init)) .route("/invoicestatus", post(invoice_status)) .route("/issueassetcfa", post(issue_asset_cfa)) @@ -153,6 +157,7 @@ pub(crate) async fn app(args: UserArgs) -> Result<(Router, Arc), AppEr .route("/sendbtc", post(send_btc)) .route("/sendonionmessage", post(send_onion_message)) .route("/sendpayment", post(send_payment)) + .route("/settlehodlinvoice", post(settle_hodl_invoice)) .route("/shutdown", post(shutdown)) .route("/signmessage", post(sign_message)) .route("/sync", post(sync)) diff --git a/src/routes.rs b/src/routes.rs index a2669fc..8ebee63 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -79,7 +79,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}, @@ -88,7 +89,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, }, @@ -557,6 +558,17 @@ pub(crate) struct GetPaymentResponse { pub(crate) payment: Payment, } +#[derive(Deserialize, Serialize)] +pub(crate) struct GetPaymentPreimageRequest { + pub(crate) payment_hash: String, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct GetPaymentPreimageResponse { + pub(crate) status: HTLCStatus, + pub(crate) preimage: Option, +} + #[derive(Deserialize, Serialize)] pub(crate) struct GetSwapRequest { pub(crate) payment_hash: String, @@ -572,14 +584,18 @@ pub(crate) struct GetSwapResponse { #[display(inner)] pub(crate) enum HTLCStatus { Pending, + Claimable, Succeeded, + Cancelled, Failed, } impl_writeable_tlv_based_enum!(HTLCStatus, (0, Pending) => {}, - (1, Succeeded) => {}, - (2, Failed) => {}, + (1, Claimable) => {}, + (2, Succeeded) => {}, + (3, Cancelled) => {}, + (4, Failed) => {}, ); #[derive(Debug, Deserialize, Serialize)] @@ -611,6 +627,7 @@ pub(crate) struct InitResponse { pub(crate) enum InvoiceStatus { Pending, Succeeded, + Cancelled, Failed, Expired, } @@ -625,6 +642,17 @@ pub(crate) struct InvoiceStatusResponse { pub(crate) status: InvoiceStatus, } +#[derive(Deserialize, Serialize)] +pub(crate) struct InvoiceSettleRequest { + pub(crate) payment_hash: String, + pub(crate) payment_preimage: String, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct InvoiceCancelRequest { + pub(crate) payment_hash: String, +} + #[derive(Deserialize, Serialize)] pub(crate) struct IssueAssetCFARequest { pub(crate) amounts: Vec, @@ -758,6 +786,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, @@ -1378,6 +1422,63 @@ pub(crate) async fn btc_balance( Ok(Json(BtcBalanceResponse { vanilla, colored })) } +pub(crate) async fn cancel_hodl_invoice( + 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 payment_hash = validate_and_parse_payment_hash(&payload.payment_hash)?; + + 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 = match unlocked_state.claimable_payment(&payment_hash) { + Some(claimable) => claimable, + None => { + if let Some(existing) = unlocked_state.inbound_payments().get(&payment_hash) { + if matches!(existing.status, HTLCStatus::Succeeded) { + return Err(APIError::InvoiceAlreadySettled); + } + } + return Err(APIError::InvoiceNotClaimable); + } + }; + if claimable.settling.unwrap_or(false) { + return Err(APIError::InvoiceSettlingInProgress); + } + + // 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); + // Best-effort cleanup; ignore if already removed. + let _ = unlocked_state.take_claimable_payment(&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 change_password( State(state): State>, WithRejection(Json(payload), _): WithRejection, APIError>, @@ -1715,6 +1816,116 @@ pub(crate) async fn get_channel_id( Ok(Json(GetChannelIdResponse { channel_id })) } +pub(crate) async fn get_payment_preimage( + State(state): State>, + WithRejection(Json(payload), _): WithRejection, APIError>, +) -> Result, APIError> { + let guard = state.check_unlocked().await?; + let unlocked_state = guard.as_ref().unwrap(); + + let payment_hash_vec = hex_str_to_vec(&payload.payment_hash); + if payment_hash_vec.is_none() || payment_hash_vec.as_ref().unwrap().len() != 32 { + return Err(APIError::InvalidPaymentHash(payload.payment_hash)); + } + let requested_ph = PaymentHash(payment_hash_vec.unwrap().try_into().unwrap()); + + let outbound_payments = unlocked_state.outbound_payments(); + for (payment_id, payment_info) in &outbound_payments { + let payment_hash = &PaymentHash(payment_id.0); + if payment_hash == &requested_ph { + let status = payment_info.status; + let preimage = if matches!(status, HTLCStatus::Succeeded) { + payment_info.preimage.map(|p| hex_str(&p.0)) + } else { + None + }; + return Ok(Json(GetPaymentPreimageResponse { status, preimage })); + } + } + + Err(APIError::PaymentNotFound(payload.payment_hash)) +} + +pub(crate) async fn hodl_invoice( + 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 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 invoice_params = Bolt11InvoiceParameters { + amount_msats: payload.amt_msat, + invoice_expiry_delta_secs: Some(payload.expiry_sec), + payment_hash: Some(payment_hash), + contract_id, + asset_amount: payload.asset_amount, + ..Default::default() + }; + + let invoice = unlocked_state + .channel_manager + .create_bolt11_invoice(invoice_params) + .map_err(|e| APIError::FailedInvoiceCreation(e.to_string()))?; + + let created_at = get_current_timestamp(); + let payment_info = 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()); + let metadata = InvoiceMetadata { + mode: InvoiceMode::Hodl, + expected_amt_msat: payload.amt_msat.or_else(|| invoice.amount_milli_satoshis()), + expiry: Some(expiry_ts), + external_ref: payload.external_ref.clone(), + }; + + unlocked_state.add_hodl_invoice_records(payment_hash, payment_info, metadata); + + Ok(Json(InvoiceHodlResponse { + invoice: invoice.to_string(), + payment_secret: hex_str(&invoice.payment_secret().0), + })) + }) + .await +} + pub(crate) async fn init( State(state): State>, WithRejection(Json(payload), _): WithRejection, APIError>, @@ -1755,7 +1966,9 @@ pub(crate) async fn invoice_status( Some(v) => match v.status { HTLCStatus::Pending if invoice.is_expired() => InvoiceStatus::Expired, HTLCStatus::Pending => InvoiceStatus::Pending, + HTLCStatus::Claimable => InvoiceStatus::Pending, HTLCStatus::Succeeded => InvoiceStatus::Succeeded, + HTLCStatus::Cancelled => InvoiceStatus::Cancelled, HTLCStatus::Failed => InvoiceStatus::Failed, }, None => return Err(APIError::UnknownLNInvoice), @@ -2552,6 +2765,20 @@ 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), + external_ref: None, + }, + ); + Ok(Json(LNInvoiceResponse { invoice: invoice.to_string(), })) @@ -3655,6 +3882,65 @@ pub(crate) async fn send_payment( .await } +pub(crate) async fn settle_hodl_invoice( + 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 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() + .get(&payment_hash) + .cloned() + .ok_or(APIError::UnknownLNInvoice)?; + + if metadata.mode != InvoiceMode::Hodl { + 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) { + // Always validate the provided preimage hashes to the invoice hash. + let computed_hash = PaymentHash(Sha256::hash(&preimage.0).to_byte_array()); + if computed_hash != payment_hash { + return Err(APIError::InvalidPaymentPreimage); + } + 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 {})); + } + if matches!( + existing.status, + HTLCStatus::Pending | HTLCStatus::Cancelled | HTLCStatus::Failed + ) { + return Err(APIError::InvoiceNotClaimable); + } + } + + // 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); + + Ok(Json(EmptyResponse {})) + }) + .await +} + pub(crate) async fn shutdown( State(state): State>, ) -> Result, APIError> { diff --git a/src/test/hodl_invoice.rs b/src/test/hodl_invoice.rs new file mode 100644 index 0000000..c3d2984 --- /dev/null +++ b/src/test/hodl_invoice.rs @@ -0,0 +1,1227 @@ +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_str(&preimage); + let payment_hash = hex_str(&Sha256::hash(&preimage).to_byte_array()); + (preimage_hex, payment_hash) +} + +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_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; + + let node2_pubkey = node_info(node2_addr).await.pubkey; + let _channel = open_channel( + node1_addr, + &node2_pubkey, + Some(node2_port), + Some(500000), + Some(0), + None, + None, + ) + .await; + + (node1_addr, node2_addr, test_dir_node1, test_dir_node2) +} + +async fn setup_two_nodes_with_asset_channel( + test_dir_suffix: &str, + port_offset: u16, + asset_channel_amount: u64, +) -> (SocketAddr, SocketAddr, String, 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_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; + + let asset_id = issue_asset_nia(node1_addr).await.asset_id; + // Create more UTXOs after issuing asset, as asset issuance consumes UTXOs + fund_and_create_utxos(node1_addr, None).await; + + let node2_pubkey = node_info(node2_addr).await.pubkey; + let _channel = open_channel( + node1_addr, + &node2_pubkey, + Some(node2_port), + Some(500000), + Some(0), + Some(asset_channel_amount), + Some(&asset_id), + ) + .await; + + ( + node1_addr, + node2_addr, + test_dir_node1, + test_dir_node2, + asset_id, + ) +} + +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_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, + }; + post_and_check_error_response( + node_address, + "/settlehodlinvoice", + &payload, + expected_status, + expected_message, + expected_name, + ) + .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 }; + post_and_check_error_response( + node_address, + "/cancelhodlinvoice", + &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)) +} + +/// Check if the claimable entry is marked as settling in storage. +fn claimable_is_settling(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 + .get(&hash) + .and_then(|c| c.settling) + .unwrap_or(false)) +} + +/// 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; + } +} + +/// Poll until the claimable entry is marked as settling (bounded by timeout). +async fn wait_for_claimable_settling( + node_test_dir: &str, + payment_hash_hex: &str, +) -> Result<(), APIError> { + let t_0 = OffsetDateTime::now_utc(); + loop { + if claimable_is_settling(node_test_dir, payment_hash_hex)? { + return Ok(()); + } + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 10.0 { + return Err(APIError::Unexpected(format!( + "claimable entry for {payment_hash_hex} was not marked settling in time" + ))); + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } +} + +async fn wait_for_payment_preimage( + node_address: SocketAddr, + payment_hash_hex: &str, +) -> Result { + let t_0 = OffsetDateTime::now_utc(); + loop { + let resp = get_payment_preimage(node_address, payment_hash_hex).await; + if matches!(resp.status, HTLCStatus::Succeeded) && resp.preimage.is_some() { + return Ok(resp); + } + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 20.0 { + return Err(APIError::Unexpected(format!( + "preimage for {payment_hash_hex} was not available in time" + ))); + } + 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(), + None, + None, + ) + .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; + 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", + ); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Claimable).await; + assert_eq!(payee_payment.status, HTLCStatus::Claimable); + + // 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); + 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 + )); + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, false).await, + "wait for claimable entry to be removed", + ); + + let preimage_resp = expect_api_ok( + wait_for_payment_preimage(node1_addr, &payment_hash_hex).await, + "wait for payment preimage to be available", + ); + assert_eq!(preimage_resp.status, HTLCStatus::Succeeded); + assert_eq!(preimage_resp.preimage, Some(preimage_hex)); +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn settle_hodl_invoice_rgb() { + initialize(); + + let asset_channel_amount = 100; + let asset_payment_amount = 10; + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2, asset_id) = + setup_two_nodes_with_asset_channel("settle-rgb", 60, asset_channel_amount).await; + + let initial_ln_balance_node1 = asset_balance_offchain_outbound(node1_addr, &asset_id).await; + let initial_ln_balance_node2 = asset_balance_offchain_outbound(node2_addr, &asset_id).await; + + let (preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let InvoiceHodlResponse { invoice, .. } = invoice_hodl( + node2_addr, + Some(HTLC_MIN_MSAT), + 900, + payment_hash_hex.clone(), + Some(&asset_id), + Some(asset_payment_amount), + ) + .await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + assert_eq!(decoded.payment_hash, payment_hash_hex); + assert_eq!(decoded.amt_msat, Some(3_000_000)); + assert_eq!(decoded.asset_id, Some(asset_id.to_string())); + assert_eq!(decoded.asset_amount, Some(asset_payment_amount)); + + 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", + ); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Claimable).await; + assert_eq!(payee_payment.status, HTLCStatus::Claimable); + assert_eq!(payee_payment.asset_id, Some(asset_id.to_string())); + assert_eq!(payee_payment.asset_amount, Some(asset_payment_amount)); + + 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); + assert_eq!(payer_payment.asset_id, Some(asset_id.to_string())); + assert_eq!(payer_payment.asset_amount, Some(asset_payment_amount)); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert_eq!(payee_payment.status, HTLCStatus::Succeeded); + assert_eq!(payee_payment.asset_id, Some(asset_id.to_string())); + assert_eq!(payee_payment.asset_amount, Some(asset_payment_amount)); + expect_api_ok( + wait_for_claimable_state(&test_dir_node2, &payment_hash_hex, false).await, + "wait for claimable entry to be removed", + ); + + wait_for_ln_balance( + node1_addr, + &asset_id, + initial_ln_balance_node1 - asset_payment_amount, + ) + .await; + wait_for_ln_balance( + node2_addr, + &asset_id, + initial_ln_balance_node2 + asset_payment_amount, + ) + .await; +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn cancel_hodl_invoice_rgb() { + initialize(); + + let asset_channel_amount = 100; + let asset_payment_amount = 10; + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2, asset_id) = + setup_two_nodes_with_asset_channel("cancel-rgb", 61, asset_channel_amount).await; + + let initial_ln_rgb_balance_node1 = asset_balance_offchain_outbound(node1_addr, &asset_id).await; + let initial_ln_rgb_balance_node2 = asset_balance_offchain_outbound(node2_addr, &asset_id).await; + + // Arrange: create a HODL invoice with a fixed payment hash and RGB asset. + let (_preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let InvoiceHodlResponse { invoice, .. } = invoice_hodl( + node2_addr, + Some(HTLC_MIN_MSAT), + 900, + payment_hash_hex.clone(), + Some(&asset_id), + Some(asset_payment_amount), + ) + .await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + assert_eq!(decoded.payment_hash, payment_hash_hex); + assert_eq!(decoded.amt_msat, Some(3_000_000)); + assert_eq!(decoded.asset_id, Some(asset_id.to_string())); + assert_eq!(decoded.asset_amount, Some(asset_payment_amount)); + + // 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", + ); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Claimable).await; + assert_eq!(payee_payment.status, HTLCStatus::Claimable); + assert_eq!(payee_payment.asset_id, Some(asset_id.to_string())); + assert_eq!(payee_payment.asset_amount, Some(asset_payment_amount)); + + // 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); + assert_eq!(payer_payment.asset_id, Some(asset_id.to_string())); + assert_eq!(payer_payment.asset_amount, Some(asset_payment_amount)); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Cancelled).await; + assert_eq!(payee_payment.status, HTLCStatus::Cancelled); + assert_eq!(payee_payment.asset_id, Some(asset_id.to_string())); + assert_eq!(payee_payment.asset_amount, Some(asset_payment_amount)); + 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", + ); + + // Assert: asset balances remain unchanged (no transfer occurred on cancel). + wait_for_ln_balance(node1_addr, &asset_id, initial_ln_rgb_balance_node1).await; + wait_for_ln_balance(node2_addr, &asset_id, initial_ln_rgb_balance_node2).await; + + // Duplicate cancel should fail. + invoice_cancel_expect_error( + node2_addr, + payment_hash_hex, + StatusCode::NOT_FOUND, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; +} + +/// 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(), + None, + None, + ) + .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", + ); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Claimable).await; + assert_eq!(payee_payment.status, HTLCStatus::Claimable); + + // 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; + let _ = wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; +} + +/// Idempotent settle with wrong preimage must fail and not change persisted status. +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn settle_twice_wrong_preimage_fails() { + initialize(); + + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2) = + setup_two_nodes_with_channel("settle-twice-wrong", 6).await; + + 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(), + None, + None, + ) + .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, + "wait for claimable entry to appear", + ); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Claimable).await; + assert_eq!(payee_payment.status, HTLCStatus::Claimable); + + // First settle succeeds. + invoice_settle(node2_addr, payment_hash_hex.clone(), preimage_hex.clone()).await; + let _ = wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + + // Second settle with wrong preimage must fail and not alter status. + 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!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Succeeded + )); +} + +/// Idempotent settle after invoice expiry should still succeed. +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn settle_after_expiry_idempotent_succeeds() { + initialize(); + + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2) = + setup_two_nodes_with_channel("settle-after-expiry", 7).await; + + let (preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let InvoiceHodlResponse { invoice, .. } = invoice_hodl( + node2_addr, + Some(45_000), + 10, + payment_hash_hex.clone(), + None, + None, + ) + .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, + "wait for claimable entry to appear", + ); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Claimable).await; + assert_eq!(payee_payment.status, HTLCStatus::Claimable); + + // Settle before expiry. + invoice_settle(node2_addr, payment_hash_hex.clone(), preimage_hex.clone()).await; + let _ = wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + + // Let invoice expiry elapse and call settle again: should still succeed idempotently. + tokio::time::sleep(std::time::Duration::from_secs(45)).await; + invoice_settle(node2_addr, payment_hash_hex.clone(), preimage_hex.clone()).await; + let _ = wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Succeeded + )); +} + +/// Cancel and then try to cancel again (the second call fails). +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn cancel_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("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(), + None, + None, + ) + .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", + ); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Claimable).await; + assert_eq!(payee_payment.status, HTLCStatus::Claimable); + + // 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", + ); + + // Duplicate cancel should fail. + invoice_cancel_expect_error( + node2_addr, + payment_hash_hex, + StatusCode::NOT_FOUND, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; +} + +/// Cancelling 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(), + None, + None, + ) + .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", + ); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Claimable).await; + assert_eq!(payee_payment.status, HTLCStatus::Claimable); + + invoice_cancel(node2_addr, payment_hash_hex.clone()).await; + + invoice_settle_expect_error( + node2_addr, + payment_hash_hex.clone(), + preimage_hex, + StatusCode::NOT_FOUND, + "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(), + None, + None, + ) + .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", + ); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Claimable).await; + assert_eq!(payee_payment.status, HTLCStatus::Claimable); + + 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::CONFLICT, + "Invoice is already settled", + "InvoiceAlreadySettled", + ) + .await; +} + +/// Cancel should be rejected while a settle is in progress. +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn cancel_while_settling_fails() { + initialize(); + + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2) = + setup_two_nodes_with_channel("cancel-while-settling", 13).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(), + None, + None, + ) + .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", + ); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Claimable).await; + assert_eq!(payee_payment.status, HTLCStatus::Claimable); + + invoice_settle(node2_addr, payment_hash_hex.clone(), preimage_hex).await; + expect_api_ok( + wait_for_claimable_settling(&test_dir_node2, &payment_hash_hex).await, + "claimable entry should be marked settling", + ); + + // Prefer the settling-in-progress error; accept already-settled if the race completes first. + let payload = InvoiceCancelRequest { + payment_hash: payment_hash_hex.clone(), + }; + let res = reqwest::Client::new() + .post(format!("http://{node2_addr}/cancelhodlinvoice")) + .json(&payload) + .send() + .await + .unwrap(); + + // Racy by nature: if settlement completes first, we see 409 instead of 403. + if res.status() == StatusCode::FORBIDDEN { + check_response_is_nok( + res, + StatusCode::FORBIDDEN, + "Invoice settlement is in progress", + "InvoiceSettlingInProgress", + ) + .await; + } else if res.status() == StatusCode::CONFLICT { + check_response_is_nok( + res, + StatusCode::CONFLICT, + "Invoice is already settled", + "InvoiceAlreadySettled", + ) + .await; + } else { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + panic!("expected 403 settling-in-progress or 409 already settled, got {status}: {body}"); + } + + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert_eq!(payee_payment.status, HTLCStatus::Succeeded); +} + +/// Settle should be rejected while a settle is in progress. +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn settling_while_settling_fails() { + initialize(); + + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2) = + setup_two_nodes_with_channel("settling-while-settling", 14).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(), + None, + None, + ) + .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", + ); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Claimable).await; + assert_eq!(payee_payment.status, HTLCStatus::Claimable); + + invoice_settle(node2_addr, payment_hash_hex.clone(), preimage_hex.clone()).await; + expect_api_ok( + wait_for_claimable_settling(&test_dir_node2, &payment_hash_hex).await, + "claimable entry should be marked settling", + ); + + // Prefer the settling-in-progress error; accept already-settled if the race completes first. + invoice_settle_expect_error( + node2_addr, + payment_hash_hex.clone(), + preimage_hex, + StatusCode::FORBIDDEN, + "Invoice settlement is in progress", + "InvoiceSettlingInProgress", + ) + .await; + + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert_eq!(payee_payment.status, HTLCStatus::Succeeded); +} + +/// Expiry via short invoice timeout: ensure settle/cancel fail after expiry. +#[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(), + None, + None, + ) + .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", + ); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Claimable).await; + assert_eq!(payee_payment.status, HTLCStatus::Claimable); + + // 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::NOT_FOUND, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; + invoice_cancel_expect_error( + node2_addr, + payment_hash_hex, + StatusCode::NOT_FOUND, + "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(), + None, + None, + ) + .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", + ); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Claimable).await; + assert_eq!(payee_payment.status, HTLCStatus::Claimable); + + // Mine past the claim deadline height (reported by LDK) to force timeout, then + // give the 30s expiry task a chance to sweep it. + let claimable_path = Path::new(&test_dir_node2) + .join(LDK_DIR) + .join(CLAIMABLE_HTLCS_FNAME); + let storage = read_claimable_htlcs(&claimable_path); + let hash = validate_and_parse_payment_hash(&payment_hash_hex).unwrap(); + let deadline_height = storage + .payments + .get(&hash) + .and_then(|c| c.claim_deadline_height) + .unwrap_or(0); + + 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::NOT_FOUND, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; + invoice_cancel_expect_error( + node2_addr, + payment_hash_hex, + StatusCode::NOT_FOUND, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; +} + +#[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 (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(), + None, + None, + ) + .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", + ); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Claimable).await; + assert_eq!(payee_payment.status, HTLCStatus::Claimable); + + // 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", + ); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Claimable).await; + assert_eq!(payee_payment.status, HTLCStatus::Claimable); + + // 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] +#[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(), + None, + None, + ) + .await; + + // Act: attempt to create another HODL invoice with the same hash. + let payload = InvoiceHodlRequest { + amt_msat: Some(10_000), + expiry_sec: 600, + asset_id: None, + asset_amount: None, + payment_hash: payment_hash_hex.clone(), + external_ref: None, + }; + post_and_check_error_response( + node1_addr, + "/hodlinvoice", + &payload, + 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 + )); +} + +/// Cancel should fail for an invoice that was never paid (no claimable HTLC). +/// TODO feat_hodl_invoice consider explicit control to align with user expectations of being able to cancel a hodl invoice. +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn cancel_unpaid_invoice_fails() { + initialize(); + + // Arrange: start a node and fund it. + let (node1_addr, _test_dir_node1) = setup_single_node("cancel_unpaid", 41).await; + + // Arrange: create a HODL invoice but never pay it. + 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(), + None, + None, + ) + .await; + + // Assert: invoice is pending (never paid). + 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 + )); +} diff --git a/src/test/mod.rs b/src/test/mod.rs index 06b9a12..b39ad19 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -1,12 +1,15 @@ use amplify::s; use biscuit_auth::{builder::date, macros::*, KeyPair}; +use bitcoin::hashes::{sha256::Hash as Sha256, Hash}; use chrono::{DateTime, Local, Utc}; use electrum_client::ElectrumApi; use lazy_static::lazy_static; use lightning_invoice::Bolt11Invoice; use once_cell::sync::Lazy; -use reqwest::Response; +use rand::RngCore; +use reqwest::{Response, StatusCode}; use rgb_lib::BitcoinNetwork; +use serde::Serialize; use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -17,7 +20,8 @@ use tokio::io::AsyncReadExt; use tokio::net::TcpListener; use tracing_test::traced_test; -use crate::error::APIErrorResponse; +use crate::disk::{read_claimable_htlcs, CLAIMABLE_HTLCS_FNAME}; +use crate::error::{APIError, APIErrorResponse}; use crate::ldk::FEE_RATE; use crate::routes::{ AddressResponse, AssetBalanceRequest, AssetBalanceResponse, AssetCFA, AssetNIA, AssetUDA, @@ -26,21 +30,25 @@ use crate::routes::{ DecodeLNInvoiceResponse, DecodeRGBInvoiceRequest, DecodeRGBInvoiceResponse, DisconnectPeerRequest, EmptyResponse, FailTransfersRequest, FailTransfersResponse, GetAssetMediaRequest, GetAssetMediaResponse, GetChannelIdRequest, GetChannelIdResponse, - GetPaymentRequest, GetPaymentResponse, GetSwapRequest, GetSwapResponse, HTLCStatus, - InitRequest, InitResponse, InvoiceStatus, InvoiceStatusRequest, InvoiceStatusResponse, - IssueAssetCFARequest, IssueAssetCFAResponse, IssueAssetNIARequest, IssueAssetNIAResponse, - IssueAssetUDARequest, IssueAssetUDAResponse, KeysendRequest, KeysendResponse, LNInvoiceRequest, - LNInvoiceResponse, ListAssetsRequest, ListAssetsResponse, ListChannelsResponse, - ListPaymentsResponse, ListPeersResponse, ListSwapsResponse, ListTransactionsRequest, - ListTransactionsResponse, ListTransfersRequest, ListTransfersResponse, ListUnspentsRequest, - ListUnspentsResponse, MakerExecuteRequest, MakerInitRequest, MakerInitResponse, - NetworkInfoResponse, NodeInfoResponse, OpenChannelRequest, OpenChannelResponse, Payment, Peer, - PostAssetMediaResponse, RefreshRequest, RestoreRequest, RevokeTokenRequest, RgbInvoiceRequest, - RgbInvoiceResponse, SendAssetRequest, SendAssetResponse, SendBtcRequest, SendBtcResponse, - SendPaymentRequest, SendPaymentResponse, Swap, SwapStatus, TakerRequest, Transaction, Transfer, - UnlockRequest, Unspent, WitnessData, + GetPaymentPreimageRequest, GetPaymentPreimageResponse, GetPaymentRequest, GetPaymentResponse, + GetSwapRequest, GetSwapResponse, HTLCStatus, InitRequest, InitResponse, InvoiceCancelRequest, + InvoiceHodlRequest, InvoiceHodlResponse, InvoiceSettleRequest, InvoiceStatus, + InvoiceStatusRequest, InvoiceStatusResponse, IssueAssetCFARequest, IssueAssetCFAResponse, + IssueAssetNIARequest, IssueAssetNIAResponse, IssueAssetUDARequest, IssueAssetUDAResponse, + KeysendRequest, KeysendResponse, LNInvoiceRequest, LNInvoiceResponse, ListAssetsRequest, + ListAssetsResponse, ListChannelsResponse, ListPaymentsResponse, ListPeersResponse, + ListSwapsResponse, ListTransactionsRequest, ListTransactionsResponse, ListTransfersRequest, + ListTransfersResponse, ListUnspentsRequest, ListUnspentsResponse, MakerExecuteRequest, + MakerInitRequest, MakerInitResponse, NetworkInfoResponse, NodeInfoResponse, OpenChannelRequest, + OpenChannelResponse, Payment, Peer, PostAssetMediaResponse, RefreshRequest, RestoreRequest, + RevokeTokenRequest, RgbInvoiceRequest, RgbInvoiceResponse, SendAssetRequest, SendAssetResponse, + SendBtcRequest, SendBtcResponse, SendPaymentRequest, SendPaymentResponse, Swap, SwapStatus, + TakerRequest, Transaction, Transfer, UnlockRequest, Unspent, WitnessData, HTLC_MIN_MSAT, +}; +use crate::utils::{ + hex_str, hex_str_to_vec, validate_and_parse_payment_hash, ELECTRUM_URL_REGTEST, LDK_DIR, + PROXY_ENDPOINT_LOCAL, }; -use crate::utils::{hex_str_to_vec, ELECTRUM_URL_REGTEST, PROXY_ENDPOINT_LOCAL}; use super::*; @@ -102,6 +110,23 @@ async fn check_response_is_nok( assert_eq!(api_error_response.name, expected_name); } +async fn post_and_check_error_response( + 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; +} + fn _fund_wallet(address: String) { let status = Command::new("docker") .stdin(Stdio::null()) @@ -549,6 +574,63 @@ 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, + asset_id: Option<&str>, + asset_amount: Option, +) -> InvoiceHodlResponse { + println!("creating HODL invoice on node {node_address}"); + let payload = InvoiceHodlRequest { + amt_msat, + expiry_sec, + asset_id: asset_id.map(|id| id.to_string()), + asset_amount, + payment_hash, + external_ref: None, + }; + let res = reqwest::Client::new() + .post(format!("http://{node_address}/hodlinvoice")) + .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}/settlehodlinvoice")) + .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}/cancelhodlinvoice")) + .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; @@ -795,6 +877,27 @@ async fn get_payment(node_address: SocketAddr, payment_hash: &str) -> Payment { .payment } +async fn get_payment_preimage( + node_address: SocketAddr, + payment_hash: &str, +) -> GetPaymentPreimageResponse { + println!("getting payment preimage for node {node_address}"); + let payload = GetPaymentPreimageRequest { + payment_hash: payment_hash.to_string(), + }; + let res = reqwest::Client::new() + .post(format!("http://{node_address}/getpaymentpreimage")) + .json(&payload) + .send() + .await + .unwrap(); + _check_response_is_ok(res) + .await + .json::() + .await + .unwrap() +} + async fn list_peers(node_address: SocketAddr) -> Vec { println!("listing peers for node {node_address}"); let res = reqwest::Client::new() @@ -1177,8 +1280,8 @@ async fn open_channel_raw( && c.asset_id == asset_id.map(|id| id.to_string()) && c.asset_local_amount == asset_amount }) { - if channel.funding_txid.is_some() { - let txout = _get_txout(channel.funding_txid.as_ref().unwrap()); + if let Some(funding_txid) = &channel.funding_txid { + let txout = _get_txout(funding_txid); if !txout.is_empty() { mine_n_blocks(false, 6); channel_id = Some(channel.channel_id.clone()); @@ -1806,6 +1909,7 @@ mod concurrent_btc_payments; mod concurrent_openchannel; mod fail_transfers; mod getchannelid; +mod hodl_invoice; mod htlc_amount_checks; mod invoice; mod issue; diff --git a/src/utils.rs b/src/utils.rs index c214f08..6dbab5e 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; @@ -11,6 +13,7 @@ use lightning::routing::router::{ use lightning::{ onion_message::packet::OnionMessageContents, sign::KeysManager, + types::payment::{PaymentHash, PaymentPreimage}, util::ser::{Writeable, Writer}, }; use lightning_persister::fs_store::FilesystemStore; @@ -38,9 +41,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 +98,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 +123,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() } @@ -445,3 +458,47 @@ 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. +/// TODO feat_submarine_rgb: this function will also be used in the new submarine swap of rgb assets task +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) +}