From 8813ddc1dd2bcb2861e68f99b9d89be299d4590b Mon Sep 17 00:00:00 2001 From: Xalkan Date: Mon, 9 Mar 2026 19:26:26 -0300 Subject: [PATCH] add support for HODL invoices --- README.md | 2 + openapi.yaml | 87 +++++- src/error.rs | 34 +++ src/ldk.rs | 215 +++++++++++-- src/main.rs | 22 +- src/routes.rs | 222 ++++++++++++-- src/test/hodl_invoice.rs | 632 +++++++++++++++++++++++++++++++++++++++ src/test/invoice.rs | 6 + src/test/mod.rs | 115 ++++++- src/test/payment.rs | 4 +- src/utils.rs | 29 ++ 11 files changed, 1284 insertions(+), 84 deletions(-) create mode 100644 src/test/hodl_invoice.rs diff --git a/README.md b/README.md index 43fd1961..53b743db 100644 --- a/README.md +++ b/README.md @@ -203,9 +203,11 @@ The node currently exposes the following APIs: - `/assetmetadata` (POST) - `/backup` (POST) - `/btcbalance` (POST) +- `/cancelhodlinvoice` (POST) - `/changepassword` (POST) - `/checkindexerurl` (POST) - `/checkproxyendpoint` (POST) +- `/claimhodlinvoice` (POST) - `/closechannel` (POST) - `/connectpeer` (POST) - `/createutxos` (POST) diff --git a/openapi.yaml b/openapi.yaml index 52a3fd54..4e0fc109 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: @@ -169,6 +187,24 @@ paths: application/json: schema: $ref: '#/components/schemas/EmptyResponse' + /claimhodlinvoice: + post: + tags: + - Invoices + summary: Claim a HODL invoice + description: Claim a held HTLC for a HODL invoice + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ClaimHodlInvoiceRequest' + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ClaimHodlInvoiceResponse' /closechannel: post: tags: @@ -674,7 +710,7 @@ paths: tags: - Invoices summary: Get a LN invoice - description: Get a LN invoice to receive a payment + description: Get a LN invoice to receive a payment. Provide `payment_hash` to create a HODL invoice. requestBody: content: application/json: @@ -1416,6 +1452,14 @@ components: $ref: '#/components/schemas/BtcBalance' colored: $ref: '#/components/schemas/BtcBalance' + CancelHodlInvoiceRequest: + type: object + required: + - payment_hash + properties: + payment_hash: + type: string + example: 3febfae1e68b190c15461f4c2a3290f9af1dae63fd7d620d2bd61601869026cd ChangePasswordRequest: type: object required: @@ -1538,6 +1582,24 @@ components: proxy_endpoint: type: string example: rpc://127.0.0.1:3000/json-rpc + ClaimHodlInvoiceRequest: + type: object + required: + - payment_hash + - payment_preimage + properties: + payment_hash: + type: string + example: b4cb2da889477082a2e47f37a07e646e60ef6f97ffa7a4d88c823efd673da94b + payment_preimage: + type: string + example: eade701c7b23b8799465f4284ad84710fc16a776fbc6483001291149122695a8 + ClaimHodlInvoiceResponse: + type: object + properties: + changed: + type: boolean + example: true CloseChannelRequest: type: object required: @@ -1819,7 +1881,10 @@ components: type: string enum: - Pending + - Claimable + - Claiming - Succeeded + - Cancelled - Failed IndexerProtocol: type: string @@ -1882,7 +1947,10 @@ components: type: string enum: - Pending + - Claimable + - Claiming - Succeeded + - Cancelled - Failed - Expired InvoiceStatusRequest: @@ -2238,6 +2306,10 @@ components: - integer - 'null' example: 42 + payment_hash: + type: string + description: Optional. When provided, the invoice is created as HODL. + example: 3febfae1e68b190c15461f4c2a3290f9af1dae63fd7d620d2bd61601869026cd LNInvoiceResponse: type: object required: @@ -2467,11 +2539,17 @@ components: temporary_channel_id: type: string example: a8b60c8ce3067b5fc881d4831323e24751daec3b64353c8df3205ec5d838f1c5 + PaymentType: + type: string + enum: + - Outbound + - InboundAutoClaim + - InboundHodl Payment: type: object required: - payment_hash - - inbound + - payment_type - status - created_at - updated_at @@ -2495,9 +2573,8 @@ components: payment_hash: type: string example: 3febfae1e68b190c15461f4c2a3290f9af1dae63fd7d620d2bd61601869026cd - inbound: - type: boolean - example: true + payment_type: + $ref: '#/components/schemas/PaymentType' status: $ref: '#/components/schemas/HTLCStatus' created_at: diff --git a/src/error.rs b/src/error.rs index 7c8fe7a7..160efaf7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -167,6 +167,9 @@ pub enum APIError { #[error("Invalid payment hash: {0}")] InvalidPaymentHash(String), + #[error("Invalid payment preimage")] + InvalidPaymentPreimage, + #[error("Invalid payment secret")] InvalidPaymentSecret, @@ -221,6 +224,24 @@ pub enum APIError { #[error("Invalid transport endpoints: {0}")] InvalidTransportEndpoints(String), + #[error("HTLC claim deadline exceeded")] + ClaimDeadlineExceeded, + + #[error("Invoice is already claimed")] + InvoiceAlreadyClaimed, + + #[error("Invoice is expired")] + InvoiceExpired, + + #[error("No claimable HTLC found for this invoice")] + InvoiceNotClaimable, + + #[error("Invoice is not marked as HODL")] + InvoiceNotHodl, + + #[error("Invoice settlement is in progress")] + InvoiceSettlingInProgress, + #[error("IO error: {0}")] IO(#[from] std::io::Error), @@ -266,6 +287,9 @@ pub enum APIError { #[error("Output below the dust limit")] OutputBelowDustLimit, + #[error("Payment hash already used")] + PaymentHashAlreadyUsed, + #[error("Payment not found: {0}")] PaymentNotFound(String), @@ -467,6 +491,8 @@ impl IntoResponse for APIError { | APIError::InvalidOnionData(_) | APIError::InvalidPassword(_) | APIError::InvalidPaymentHash(_) + | APIError::PaymentHashAlreadyUsed + | APIError::InvalidPaymentPreimage | APIError::InvalidPaymentSecret | APIError::InvalidPeerInfo(_) | APIError::InvalidPrecision(_) @@ -482,10 +508,12 @@ impl IntoResponse for APIError { | APIError::InvalidTlvType(_) | APIError::InvalidTransportEndpoint(_) | APIError::InvalidTransportEndpoints(_) + | APIError::InvoiceExpired | APIError::MediaFileEmpty | APIError::MediaFileNotProvided | APIError::MissingSwapPaymentPreimage | APIError::OutputBelowDustLimit + | APIError::ClaimDeadlineExceeded | APIError::UnsupportedBackupVersion { .. } => { (StatusCode::BAD_REQUEST, self.to_string(), self.name()) } @@ -510,6 +538,8 @@ impl IntoResponse for APIError { | APIError::InvalidIndexer(_) | APIError::InvalidProxyEndpoint | APIError::InvalidProxyProtocol(_) + | APIError::InvoiceNotHodl + | APIError::InvoiceSettlingInProgress | APIError::LockedNode | APIError::MaxFeeExceeded(_) | APIError::MinFeeNotMet(_) @@ -532,6 +562,10 @@ impl IntoResponse for APIError { | APIError::UnsupportedTransportType => { (StatusCode::FORBIDDEN, self.to_string(), self.name()) } + APIError::InvoiceAlreadyClaimed => { + (StatusCode::CONFLICT, self.to_string(), self.name()) + } + APIError::InvoiceNotClaimable => (StatusCode::NOT_FOUND, 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 9dfd0261..2565ff20 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; @@ -111,6 +111,17 @@ pub(crate) const FEE_RATE: u64 = 7; pub(crate) const UTXO_SIZE_SAT: u32 = 32000; pub(crate) const MIN_CHANNEL_CONFIRMATIONS: u8 = 6; +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum InvoiceType { + AutoClaim, + Hodl, +} + +impl_writeable_tlv_based_enum!(InvoiceType, + (0, AutoClaim) => {}, + (1, Hodl) => {}, +); + pub(crate) struct LdkBackgroundServices { stop_processing: Arc, peer_manager: Arc, @@ -128,6 +139,8 @@ pub(crate) struct PaymentInfo { pub(crate) updated_at: u64, pub(crate) payee_pubkey: PublicKey, pub(crate) expires_at: Option, + pub(crate) claim_deadline_height: Option, + pub(crate) invoice_type: Option, } impl_writeable_tlv_based!(PaymentInfo, { @@ -139,6 +152,8 @@ impl_writeable_tlv_based!(PaymentInfo, { (10, updated_at, required), (12, payee_pubkey, required), (14, expires_at, option), + (16, claim_deadline_height, option), + (18, invoice_type, option), }); pub(crate) struct InboundPaymentInfoStorage { @@ -266,6 +281,24 @@ impl UnlockedAppState { Ok(()) } + pub(crate) fn fail_htlc_backwards_and_update_inbound_payment( + &self, + payment_hash: PaymentHash, + status: HTLCStatus, + ) { + self.channel_manager.fail_htlc_backwards(&payment_hash); + self.upsert_inbound_payment( + payment_hash, + status, + None, + None, + None, + self.channel_manager.get_our_node_id(), + None, + None, + ); + } + fn fail_outbound_pending_payments(&self, recent_payments_payment_ids: Vec) { let mut outbound = self.get_outbound_payments(); let mut failed = false; @@ -287,26 +320,68 @@ impl UnlockedAppState { pub(crate) fn list_updated_inbound_payments(&self) -> LdkHashMap { let now = get_current_timestamp(); + let height = self.channel_manager.current_best_block().height; let mut inbound = self.get_inbound_payments(); let mut failed = false; - for (_, payment_info) in inbound - .payments - .iter_mut() - .filter(|(_, i)| matches!(i.status, HTLCStatus::Pending)) - { - if let Some(expires_at) = payment_info.expires_at { - if now > expires_at { - payment_info.status = HTLCStatus::Failed; - payment_info.updated_at = now; - failed = true; + let mut claimables_to_fail = vec![]; + for (payment_hash, payment_info) in inbound.payments.iter_mut() { + match payment_info.status { + HTLCStatus::Pending => { + if let Some(expires_at) = payment_info.expires_at { + if now > expires_at { + payment_info.status = HTLCStatus::Failed; + payment_info.updated_at = now; + failed = true; + } + } } + HTLCStatus::Claimable => { + let deadline_passed = payment_info + .claim_deadline_height + .map(|h| height >= h) + .unwrap_or(false); + let invoice_expired = payment_info + .expires_at + .map(|expires_at| now >= expires_at) + .unwrap_or(false); + + if deadline_passed || invoice_expired { + claimables_to_fail.push(( + *payment_hash, + payment_info.claim_deadline_height, + payment_info.expires_at, + )); + } + } + _ => {} + } + } + + if claimables_to_fail.is_empty() { + let payments = inbound.payments.clone(); + if failed { + self.save_inbound_payments(inbound); } + return payments; } - let payments = inbound.payments.clone(); + if failed { self.save_inbound_payments(inbound); + } else { + drop(inbound); } - payments + + for (payment_hash, claim_deadline_height, expires_at) in claimables_to_fail { + tracing::info!( + "Expiring claimable payment {:?} (deadline: {:?}, expiry: {:?})", + payment_hash, + claim_deadline_height, + expires_at + ); + self.fail_htlc_backwards_and_update_inbound_payment(payment_hash, HTLCStatus::Failed); + } + + self.inbound_payments() } pub(crate) fn inbound_payments(&self) -> LdkHashMap { @@ -317,7 +392,7 @@ impl UnlockedAppState { self.get_outbound_payments().payments.clone() } - fn save_inbound_payments(&self, inbound: MutexGuard) { + pub(crate) fn save_inbound_payments(&self, inbound: MutexGuard) { self.fs_store .write("", "", INBOUND_PAYMENTS_FNAME, inbound.encode()) .unwrap(); @@ -329,7 +404,8 @@ impl UnlockedAppState { .unwrap(); } - fn upsert_inbound_payment( + #[allow(clippy::too_many_arguments)] + pub(crate) fn upsert_inbound_payment( &self, payment_hash: PaymentHash, status: HTLCStatus, @@ -337,6 +413,8 @@ impl UnlockedAppState { secret: Option, amt_msat: Option, payee_pubkey: PublicKey, + claim_deadline_height: Option, + invoice_type: Option, ) { let mut inbound = self.get_inbound_payments(); match inbound.payments.entry(payment_hash) { @@ -349,6 +427,9 @@ impl UnlockedAppState { payment_info.amt_msat = amt_msat; } payment_info.updated_at = get_current_timestamp(); + if claim_deadline_height.is_some() { + payment_info.claim_deadline_height = claim_deadline_height; + } } Entry::Vacant(e) => { let created_at = get_current_timestamp(); @@ -361,6 +442,8 @@ impl UnlockedAppState { updated_at: created_at, payee_pubkey, expires_at: None, + claim_deadline_height, + invoice_type, }); } } @@ -720,7 +803,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: _, @@ -731,21 +814,93 @@ async fn handle_ldk_events( payment_hash, amount_msat, ); - let payment_preimage = match purpose { + + let (payment_preimage, payment_secret, invoice) = match purpose { + PaymentPurpose::SpontaneousPayment(preimage) => { + unlocked_state.channel_manager.claim_funds(preimage); + return Ok(()); + } PaymentPurpose::Bolt11InvoicePayment { - payment_preimage, .. - } => payment_preimage, - PaymentPurpose::Bolt12OfferPayment { - payment_preimage, .. - } => payment_preimage, - PaymentPurpose::Bolt12RefundPayment { - payment_preimage, .. - } => payment_preimage, - PaymentPurpose::SpontaneousPayment(preimage) => Some(preimage), + payment_preimage, + payment_secret, + .. + } + | PaymentPurpose::Bolt12OfferPayment { + payment_preimage, + payment_secret, + .. + } + | PaymentPurpose::Bolt12RefundPayment { + payment_preimage, + payment_secret, + .. + } => { + let Some(invoice) = unlocked_state + .get_inbound_payments() + .payments + .get(&payment_hash) + .cloned() + else { + tracing::error!( + "Missing inbound payment state for claimable payment {:?}", + payment_hash + ); + return Err(ReplayEvent()); + }; + + (payment_preimage, Some(payment_secret), invoice) + } }; - unlocked_state - .channel_manager - .claim_funds(payment_preimage.unwrap()); + + let now_ts = get_current_timestamp(); + if let Some(expiry) = invoice.expires_at { + if now_ts >= expiry { + tracing::warn!( + "Received HTLC for expired invoice {payment_hash:?} (expiry {expiry})" + ); + unlocked_state.fail_htlc_backwards_and_update_inbound_payment( + payment_hash, + HTLCStatus::Failed, + ); + return Ok(()); + } + } + + if let Some(expected) = invoice.amt_msat { + if amount_msat < expected { + tracing::warn!( + "Received {} msat for invoice {} but expected at least {} msat", + amount_msat, + payment_hash, + expected + ); + unlocked_state.fail_htlc_backwards_and_update_inbound_payment( + payment_hash, + HTLCStatus::Failed, + ); + return Ok(()); + } + } + + match invoice.invoice_type.unwrap_or(InvoiceType::AutoClaim) { + InvoiceType::AutoClaim => { + unlocked_state + .channel_manager + .claim_funds(payment_preimage.unwrap()); + } + InvoiceType::Hodl => { + unlocked_state.upsert_inbound_payment( + payment_hash, + HTLCStatus::Claimable, + payment_preimage, + payment_secret, + Some(amount_msat), + unlocked_state.channel_manager.get_our_node_id(), + claim_deadline, + None, + ); + } + } } Event::PaymentClaimed { payment_hash, @@ -812,6 +967,8 @@ async fn handle_ldk_events( payment_secret, Some(amount_msat), receiver_node_id.unwrap(), + None, + None, ); } } diff --git a/src/main.rs b/src/main.rs index 4fcc5bbd..ad9cdc46 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,16 +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, inflate, init, invoice_status, - issue_asset_cfa, issue_asset_ifa, 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_btc, send_onion_message, send_payment, send_rgb, 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, claim_hodl_invoice, 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, inflate, + init, invoice_status, issue_asset_cfa, issue_asset_ifa, 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_btc, send_onion_message, send_payment, send_rgb, shutdown, sign_message, + sync, taker, unlock, }; use crate::utils::{start_daemon, AppState, LOGS_DIR}; @@ -110,9 +110,11 @@ 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)) + .route("/claimhodlinvoice", post(claim_hodl_invoice)) .route("/closechannel", post(close_channel)) .route("/connectpeer", post(connect_peer)) .route("/createutxos", post(create_utxos)) diff --git a/src/routes.rs b/src/routes.rs index 70f8257e..a7ca624a 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -80,7 +80,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}, @@ -89,7 +90,7 @@ use crate::{ use crate::{ disk::{self, CHANNEL_PEER_DATA}, error::APIError, - ldk::{PaymentInfo, FEE_RATE, UTXO_SIZE_SAT}, + ldk::{InvoiceType, PaymentInfo, FEE_RATE, UTXO_SIZE_SAT}, utils::{ connect_peer_if_necessary, get_current_timestamp, no_cancel, parse_peer_info, AppState, }, @@ -412,6 +413,11 @@ pub(crate) struct BtcBalanceResponse { pub(crate) colored: BtcBalance, } +#[derive(Deserialize, Serialize)] +pub(crate) struct CancelHodlInvoiceRequest { + pub(crate) payment_hash: String, +} + #[derive(Debug, Deserialize, Serialize)] pub(crate) struct ChangePasswordRequest { pub(crate) old_password: String, @@ -463,6 +469,17 @@ pub(crate) struct CheckProxyEndpointRequest { pub(crate) proxy_endpoint: String, } +#[derive(Deserialize, Serialize)] +pub(crate) struct ClaimHodlInvoiceRequest { + pub(crate) payment_hash: String, + pub(crate) payment_preimage: String, +} + +#[derive(Deserialize, Serialize)] +pub(crate) struct ClaimHodlInvoiceResponse { + pub(crate) changed: bool, +} + #[derive(Deserialize, Serialize)] pub(crate) struct CloseChannelRequest { pub(crate) channel_id: String, @@ -609,7 +626,10 @@ pub(crate) struct GetSwapResponse { #[display(inner)] pub(crate) enum HTLCStatus { Pending, + Claimable, + Claiming, Succeeded, + Cancelled, Failed, } @@ -617,6 +637,9 @@ impl_writeable_tlv_based_enum!(HTLCStatus, (0, Pending) => {}, (1, Succeeded) => {}, (2, Failed) => {}, + (3, Claimable) => {}, + (4, Claiming) => {}, + (5, Cancelled) => {}, ); #[derive(Debug, Deserialize, Serialize)] @@ -661,7 +684,10 @@ pub(crate) struct InitResponse { #[derive(Clone, Copy, Deserialize, Serialize)] pub(crate) enum InvoiceStatus { Pending, + Claimable, + Claiming, Succeeded, + Cancelled, Failed, Expired, } @@ -818,6 +844,7 @@ pub(crate) struct LNInvoiceRequest { pub(crate) expiry_sec: u32, pub(crate) asset_id: Option, pub(crate) asset_amount: Option, + pub(crate) payment_hash: Option, } #[derive(Deserialize, Serialize)] @@ -917,13 +944,20 @@ pub(crate) struct OpenChannelResponse { pub(crate) temporary_channel_id: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub(crate) enum PaymentType { + Outbound, + InboundAutoClaim, + InboundHodl, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub(crate) struct Payment { pub(crate) amt_msat: Option, pub(crate) asset_amount: Option, pub(crate) asset_id: Option, pub(crate) payment_hash: String, - pub(crate) inbound: bool, + pub(crate) payment_type: PaymentType, pub(crate) status: HTLCStatus, pub(crate) created_at: u64, pub(crate) updated_at: u64, @@ -931,6 +965,13 @@ pub(crate) struct Payment { pub(crate) preimage: Option, } +fn payment_type_from_invoice(invoice_type: Option) -> PaymentType { + match invoice_type.unwrap_or(InvoiceType::AutoClaim) { + InvoiceType::AutoClaim => PaymentType::InboundAutoClaim, + InvoiceType::Hodl => PaymentType::InboundHodl, + } +} + #[derive(Clone, Deserialize, Serialize)] pub(crate) struct Peer { pub(crate) pubkey: String, @@ -1466,6 +1507,39 @@ 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 payment_info = unlocked_state + .get_inbound_payments() + .payments + .get(&payment_hash) + .cloned() + .ok_or(APIError::UnknownLNInvoice)?; + if !matches!(payment_info.invoice_type, Some(InvoiceType::Hodl)) { + return Err(APIError::InvoiceNotHodl); + } + match payment_info.status { + HTLCStatus::Succeeded => return Err(APIError::InvoiceAlreadyClaimed), + HTLCStatus::Claimable => {} + HTLCStatus::Claiming => return Err(APIError::InvoiceSettlingInProgress), + _ => return Err(APIError::InvoiceNotClaimable), + } + + unlocked_state + .fail_htlc_backwards_and_update_inbound_payment(payment_hash, HTLCStatus::Cancelled); + + Ok(Json(EmptyResponse {})) + }) + .await +} + pub(crate) async fn change_password( State(state): State>, WithRejection(Json(payload), _): WithRejection, APIError>, @@ -1507,6 +1581,74 @@ pub(crate) async fn check_proxy_endpoint( Ok(Json(EmptyResponse {})) } +pub(crate) async fn claim_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 mut inbound = unlocked_state.get_inbound_payments(); + let Some(existing_payment_mut) = inbound.payments.get_mut(&payment_hash) else { + return Err(APIError::UnknownLNInvoice); + }; + + if !matches!(existing_payment_mut.invoice_type, Some(InvoiceType::Hodl)) { + return Err(APIError::InvoiceNotHodl); + } + + match existing_payment_mut.status { + HTLCStatus::Succeeded => { + 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_payment_mut.preimage { + if stored_preimage != preimage { + return Err(APIError::InvalidPaymentPreimage); + } + } + + return Ok(Json(ClaimHodlInvoiceResponse { changed: false })); + } + HTLCStatus::Claiming => return Err(APIError::InvoiceSettlingInProgress), + HTLCStatus::Claimable => {} + _ => return Err(APIError::InvoiceNotClaimable), + } + + let current_height = unlocked_state.channel_manager.current_best_block().height; + let now_ts = get_current_timestamp(); + + if let Some(deadline_height) = existing_payment_mut.claim_deadline_height { + if current_height >= deadline_height { + return Err(APIError::ClaimDeadlineExceeded); + } + } + + if let Some(expiry) = existing_payment_mut.expires_at { + if now_ts >= expiry { + return Err(APIError::InvoiceExpired); + } + } + + existing_payment_mut.status = HTLCStatus::Claiming; + existing_payment_mut.updated_at = now_ts; + unlocked_state.save_inbound_payments(inbound); + } + + unlocked_state.channel_manager.claim_funds(preimage); + + Ok(Json(ClaimHodlInvoiceResponse { changed: true })) + }) + .await +} + pub(crate) async fn close_channel( State(state): State>, WithRejection(Json(payload), _): WithRejection, APIError>, @@ -1810,11 +1952,7 @@ pub(crate) async fn get_payment( 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 requested_ph = validate_and_parse_payment_hash(&payload.payment_hash)?; let inbound_payments = unlocked_state.list_updated_inbound_payments(); let outbound_payments = unlocked_state.outbound_payments(); @@ -1837,7 +1975,7 @@ pub(crate) async fn get_payment( asset_amount, asset_id, payment_hash: hex_str(&payment_hash.0), - inbound: true, + payment_type: payment_type_from_invoice(payment_info.invoice_type.clone()), status: payment_info.status, created_at: payment_info.created_at, updated_at: payment_info.updated_at, @@ -1867,7 +2005,7 @@ pub(crate) async fn get_payment( asset_amount, asset_id, payment_hash: hex_str(&payment_hash.0), - inbound: false, + payment_type: PaymentType::Outbound, status: payment_info.status, created_at: payment_info.created_at, updated_at: payment_info.updated_at, @@ -1888,11 +2026,7 @@ pub(crate) async fn get_swap( 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 requested_ph = validate_and_parse_payment_hash(&payload.payment_hash)?; let map_swap = |payment_hash: &PaymentHash, swap_data: &SwapData, taker: bool| { let mut status = swap_data.status.clone(); @@ -2017,7 +2151,10 @@ 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::Claimable, + HTLCStatus::Claiming => InvoiceStatus::Claiming, HTLCStatus::Succeeded => InvoiceStatus::Succeeded, + HTLCStatus::Cancelled => InvoiceStatus::Cancelled, HTLCStatus::Failed => InvoiceStatus::Failed, }, None => return Err(APIError::UnknownLNInvoice), @@ -2208,10 +2345,13 @@ pub(crate) async fn keysend( secret: None, status: HTLCStatus::Pending, amt_msat: Some(amt_msat), + claim_deadline_height: None, created_at, - updated_at: created_at, - payee_pubkey: dest_pubkey, expires_at: None, + invoice_type: None, + payee_pubkey: dest_pubkey, + + updated_at: created_at, }, )?; if let Some((contract_id, rgb_amount)) = rgb_payment { @@ -2453,7 +2593,7 @@ pub(crate) async fn list_payments( asset_amount, asset_id, payment_hash: hex_str(&payment_hash.0), - inbound: true, + payment_type: payment_type_from_invoice(payment_info.invoice_type.clone()), status: payment_info.status, created_at: payment_info.created_at, updated_at: payment_info.updated_at, @@ -2480,7 +2620,7 @@ pub(crate) async fn list_payments( asset_amount, asset_id, payment_hash: hex_str(&payment_hash.0), - inbound: false, + payment_type: PaymentType::Outbound, status: payment_info.status, created_at: payment_info.created_at, updated_at: payment_info.updated_at, @@ -2678,8 +2818,11 @@ pub(crate) async fn ln_invoice( 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))?) + let contract_id = if let Some(asset_id) = &payload.asset_id { + Some( + ContractId::from_str(asset_id) + .map_err(|_| APIError::InvalidAssetID(asset_id.clone()))?, + ) } else { None }; @@ -2690,24 +2833,43 @@ pub(crate) async fn ln_invoice( ))); } + let created_at = get_current_timestamp(); + let requested_payment_hash = match &payload.payment_hash { + Some(payment_hash) => { + let payment_hash = validate_and_parse_payment_hash(payment_hash)?; + if unlocked_state + .inbound_payments() + .contains_key(&payment_hash) + { + return Err(APIError::PaymentHashAlreadyUsed); + } + Some(payment_hash) + } + None => None, + }; + let invoice_params = Bolt11InvoiceParameters { amount_msats: payload.amt_msat, invoice_expiry_delta_secs: Some(payload.expiry_sec), + payment_hash: requested_payment_hash, contract_id, asset_amount: payload.asset_amount, ..Default::default() }; - let invoice = match unlocked_state + let invoice = unlocked_state .channel_manager .create_bolt11_invoice(invoice_params) - { - Ok(inv) => inv, - Err(e) => return Err(APIError::FailedInvoiceCreation(e.to_string())), + .map_err(|e| APIError::FailedInvoiceCreation(e.to_string()))?; + + let (payment_hash, invoice_type) = match requested_payment_hash { + Some(payment_hash) => (payment_hash, InvoiceType::Hodl), + None => ( + PaymentHash((*invoice.payment_hash()).to_byte_array()), + InvoiceType::AutoClaim, + ), }; - let payment_hash = PaymentHash((*invoice.payment_hash()).to_byte_array()); - let created_at = get_current_timestamp(); unlocked_state.add_inbound_payment( payment_hash, PaymentInfo { @@ -2719,6 +2881,8 @@ pub(crate) async fn ln_invoice( updated_at: created_at, payee_pubkey: unlocked_state.channel_manager.get_our_node_id(), expires_at: Some(created_at + payload.expiry_sec as u64), + claim_deadline_height: None, + invoice_type: Some(invoice_type), }, ); @@ -3675,6 +3839,8 @@ pub(crate) async fn send_payment( updated_at: created_at, payee_pubkey: offer.issuer_signing_pubkey().ok_or(APIError::InvalidInvoice(s!("missing signing pubkey")))?, expires_at: None, + claim_deadline_height: None, + invoice_type: None, }, )?; @@ -3767,6 +3933,8 @@ pub(crate) async fn send_payment( updated_at: created_at, payee_pubkey: invoice.get_payee_pub_key(), expires_at: None, + claim_deadline_height: None, + invoice_type: None, }, )?; let payment_hash = PaymentHash(invoice.payment_hash().to_byte_array()); diff --git a/src/test/hodl_invoice.rs b/src/test/hodl_invoice.rs new file mode 100644 index 00000000..da5892c6 --- /dev/null +++ b/src/test/hodl_invoice.rs @@ -0,0 +1,632 @@ +use super::*; + +const TEST_DIR_BASE: &str = "tmp/hodl_invoice/"; + +#[derive(Clone, Copy)] +enum ExpiryTrigger { + Time, + Blocks, +} + +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 = CancelHodlInvoiceRequest { payment_hash }; + + let res = reqwest::Client::new() + .post(format!("http://{node_address}/cancelhodlinvoice")) + .json(&payload) + .send() + .await + .unwrap(); + check_response_is_nok(res, expected_status, expected_message, expected_name).await +} + +async fn invoice_claim_expect_error( + node_address: SocketAddr, + payment_hash: String, + payment_preimage: String, + expected_status: StatusCode, + expected_message: &str, + expected_name: &str, +) { + println!("claiming HODL invoice {payment_hash} on node {node_address}"); + let payload = ClaimHodlInvoiceRequest { + payment_hash, + payment_preimage, + }; + + let res = reqwest::Client::new() + .post(format!("http://{node_address}/claimhodlinvoice")) + .json(&payload) + .send() + .await + .unwrap(); + check_response_is_nok(res, expected_status, expected_message, expected_name).await +} + +async fn run_auto_claim_invoice_regression_case(node1_addr: SocketAddr, node2_addr: SocketAddr) { + let LNInvoiceResponse { invoice } = + ln_invoice(node2_addr, Some(HTLC_MIN_MSAT), None, None, 120).await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Succeeded).await; + let _payer_payment = + wait_for_ln_payment(node1_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + let _payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Succeeded + )); +} + +async fn run_expire_hodl_invoice_case( + node1_addr: SocketAddr, + node2_addr: SocketAddr, + test_dir_node2: &str, + trigger: ExpiryTrigger, +) { + let (preimage_hex, payment_hash_hex) = random_preimage_and_hash(); + let expiry_sec = match trigger { + ExpiryTrigger::Time => 20, + ExpiryTrigger::Blocks => 900, + }; + let LNInvoiceResponse { invoice } = ln_invoice_hodl( + node2_addr, + Some(HTLC_MIN_MSAT), + None, + None, + expiry_sec, + Some(payment_hash_hex.clone()), + ) + .await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + assert_eq!(decoded.payment_hash, payment_hash_hex); + + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + wait_for_claimable_state(test_dir_node2, &payment_hash_hex, true) + .await + .unwrap_or_else(|err| panic!("wait for claimable entry to appear: {err}")); + let _ = wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Claimable).await; + + match trigger { + ExpiryTrigger::Time => { + let expiry_wait = + std::time::Duration::from_secs(u64::from(expiry_sec).saturating_add(60)); + let _ = wait_for_ln_payment_with_timeout( + node2_addr, + &decoded.payment_hash, + HTLCStatus::Failed, + expiry_wait, + ) + .await + .unwrap_or_else(|err| { + panic!("wait for payee payment to fail after time expiry: {err}") + }); + let _ = wait_for_ln_payment_with_timeout( + node1_addr, + &decoded.payment_hash, + HTLCStatus::Failed, + expiry_wait, + ) + .await + .unwrap_or_else(|err| { + panic!("wait for payer payment to fail after time expiry: {err}") + }); + } + ExpiryTrigger::Blocks => { + let inbound_payments_path = Path::new(test_dir_node2) + .join(LDK_DIR) + .join(INBOUND_PAYMENTS_FNAME); + let storage = read_inbound_payment_info(&inbound_payments_path); + let hash = validate_and_parse_payment_hash(&payment_hash_hex).unwrap(); + let deadline_height = storage + .payments + .get(&hash) + .and_then(|p| p.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); + + let _ = wait_for_ln_payment_with_timeout( + node2_addr, + &decoded.payment_hash, + HTLCStatus::Failed, + std::time::Duration::from_secs(60), + ) + .await + .unwrap_or_else(|err| { + panic!("wait for payee payment to fail after block-based expiry: {err}") + }); + let _ = wait_for_ln_payment_with_timeout( + node1_addr, + &decoded.payment_hash, + HTLCStatus::Failed, + std::time::Duration::from_secs(60), + ) + .await + .unwrap_or_else(|err| { + panic!("wait for payer payment to fail after block-based expiry: {err}") + }); + } + } + + wait_for_claimable_state(test_dir_node2, &payment_hash_hex, false) + .await + .unwrap_or_else(|err| panic!("wait for claimable entry to be removed: {err}")); + let payee_payment = get_payment(node2_addr, &decoded.payment_hash).await; + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Failed + )); + assert_eq!(payee_payment.payment_type, PaymentType::InboundHodl); + assert_eq!(payee_payment.status, HTLCStatus::Failed); + let payee_payment_from_list = list_payments(node2_addr) + .await + .into_iter() + .find(|payment| payment.payment_hash == decoded.payment_hash) + .unwrap(); + assert_eq!( + payee_payment_from_list.payment_type, + PaymentType::InboundHodl + ); + let payee_payment_again = get_payment(node2_addr, &decoded.payment_hash).await; + assert_eq!(payee_payment_again.payment_type, PaymentType::InboundHodl); + assert_eq!(payee_payment_again.status, HTLCStatus::Failed); + wait_for_claimable_state(test_dir_node2, &payment_hash_hex, false) + .await + .unwrap_or_else(|err| panic!("wait for claimable entry to stay removed: {err}")); + + invoice_claim_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; +} + +async fn setup_two_nodes_with_asset_channel( + test_dir_suffix: &str, + port_offset: u16, +) -> (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; + fund_and_create_utxos(node1_addr, None).await; + + let node2_pubkey = node_info(node2_addr).await.pubkey; + let _channel = open_channel_with_retry( + node1_addr, + &node2_pubkey, + Some(node2_port), + Some(500000), + Some(0), + Some(100), + Some(&asset_id), + None, + 5, + ) + .await; + + ( + node1_addr, + node2_addr, + test_dir_node1, + test_dir_node2, + asset_id, + ) +} + +async fn wait_for_claimable_state( + node_test_dir: &str, + payment_hash: &str, + expected: bool, +) -> Result<(), APIError> { + let claimable_exists = || -> Result { + let inbound_payments_path = Path::new(node_test_dir) + .join(LDK_DIR) + .join(INBOUND_PAYMENTS_FNAME); + let storage = read_inbound_payment_info(&inbound_payments_path); + let hash = validate_and_parse_payment_hash(payment_hash)?; + Ok(matches!( + storage.payments.get(&hash).map(|p| p.status), + Some(HTLCStatus::Claimable) + )) + }; + + let t_0 = OffsetDateTime::now_utc(); + loop { + if claimable_exists()? == expected { + return Ok(()); + } + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 20.0 { + return Err(APIError::Unexpected(format!( + "claimable entry for {payment_hash} did not reach state {expected}" + ))); + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } +} + +async fn wait_for_ln_payment_with_timeout( + node_address: SocketAddr, + payment_hash: &str, + expected_status: HTLCStatus, + timeout: std::time::Duration, +) -> Result { + let t_0 = std::time::Instant::now(); + loop { + if let Some(payment) = + check_payment_status(node_address, payment_hash, expected_status).await + { + return Ok(payment); + } + if t_0.elapsed() > timeout { + return Err(APIError::Unexpected(format!( + "payment {payment_hash} on {node_address} did not reach status \ + {expected_status:?} in {timeout:?}" + ))); + } + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn autoclaim_and_expire_hodl_invoice_time_and_blocks() { + initialize(); + + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2, _asset_id) = + setup_two_nodes_with_asset_channel("autoclaim-expiry", 10).await; + + run_auto_claim_invoice_regression_case(node1_addr, node2_addr).await; + run_expire_hodl_invoice_case(node1_addr, node2_addr, &test_dir_node2, ExpiryTrigger::Time) + .await; + run_expire_hodl_invoice_case( + node1_addr, + node2_addr, + &test_dir_node2, + ExpiryTrigger::Blocks, + ) + .await; +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn cancel_hodl_invoice_btc_rgb() { + initialize(); + + 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-btc-rgb-rgb", 20).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; + + let (preimage, payment_hash) = random_preimage_and_hash(); + let LNInvoiceResponse { + invoice: hodl_invoice, + } = ln_invoice_hodl( + node2_addr, + Some(HTLC_MIN_MSAT), + Some(&asset_id), + Some(asset_payment_amount), + 120, + Some(payment_hash.clone()), + ) + .await; + let decoded = decode_ln_invoice(node1_addr, &hodl_invoice).await; + assert_eq!(decoded.payment_hash, payment_hash); + assert_eq!(decoded.amt_msat, Some(HTLC_MIN_MSAT)); + assert_eq!(decoded.asset_id, Some(asset_id.clone())); + assert_eq!(decoded.asset_amount, Some(asset_payment_amount)); + + invoice_cancel_expect_error( + node2_addr, + payment_hash.clone(), + StatusCode::NOT_FOUND, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; + assert!(matches!( + invoice_status(node2_addr, &hodl_invoice).await, + InvoiceStatus::Pending + )); + + let _ = send_payment_with_status(node1_addr, hodl_invoice.clone(), HTLCStatus::Pending).await; + wait_for_claimable_state(&test_dir_node2, &payment_hash, true) + .await + .unwrap_or_else(|err| panic!("wait for claimable entry to appear: {err}")); + + let payee_claimable = + wait_for_ln_payment(node2_addr, &payment_hash, HTLCStatus::Claimable).await; + assert_eq!(payee_claimable.asset_id, Some(asset_id.clone())); + assert_eq!(payee_claimable.asset_amount, Some(asset_payment_amount)); + assert!(matches!( + invoice_status(node2_addr, &hodl_invoice).await, + InvoiceStatus::Claimable + )); + + cancel_hodl_invoice(node2_addr, payment_hash.clone()).await; + + let payer_failed = wait_for_ln_payment(node1_addr, &payment_hash, HTLCStatus::Failed).await; + assert_eq!(payer_failed.asset_id, Some(asset_id.clone())); + assert_eq!(payer_failed.asset_amount, Some(asset_payment_amount)); + + assert!(matches!( + invoice_status(node2_addr, &hodl_invoice).await, + InvoiceStatus::Cancelled + )); + + wait_for_claimable_state(&test_dir_node2, &payment_hash, false) + .await + .unwrap_or_else(|err| panic!("wait for claimable entry to be removed: {err}")); + + invoice_cancel_expect_error( + node2_addr, + payment_hash.clone(), + StatusCode::NOT_FOUND, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; + + invoice_claim_expect_error( + node2_addr, + payment_hash.clone(), + preimage, + StatusCode::NOT_FOUND, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; + + let payee_payment = wait_for_ln_payment(node2_addr, &payment_hash, HTLCStatus::Cancelled).await; + assert_eq!(payee_payment.asset_id, Some(asset_id.clone())); + assert_eq!(payee_payment.asset_amount, Some(asset_payment_amount)); + + 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; +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn claim_hodl_invoice_btc_rgb() { + initialize(); + + 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-btc-rgb", 30).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, payment_hash) = random_preimage_and_hash(); + let LNInvoiceResponse { invoice } = ln_invoice_hodl( + node2_addr, + Some(HTLC_MIN_MSAT), + Some(&asset_id), + Some(asset_payment_amount), + 120, + Some(payment_hash.clone()), + ) + .await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + assert_eq!(decoded.payment_hash, payment_hash); + assert_eq!(decoded.amt_msat, Some(HTLC_MIN_MSAT)); + assert_eq!(decoded.asset_id, Some(asset_id.to_string())); + assert_eq!(decoded.asset_amount, Some(asset_payment_amount)); + + let duplicate_hash_payload = LNInvoiceRequest { + amt_msat: Some(10_000), + expiry_sec: 60, + asset_id: None, + asset_amount: None, + payment_hash: Some(payment_hash.clone()), + }; + let duplicate_hash_res = reqwest::Client::new() + .post(format!("http://{node2_addr}/lninvoice")) + .json(&duplicate_hash_payload) + .send() + .await + .unwrap(); + check_response_is_nok( + duplicate_hash_res, + StatusCode::BAD_REQUEST, + "Payment hash already used", + "PaymentHashAlreadyUsed", + ) + .await; + + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Pending + )); + wait_for_claimable_state(&test_dir_node2, &payment_hash, true) + .await + .unwrap_or_else(|err| panic!("wait for claimable entry to appear: {err}")); + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Claimable).await; + 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::Claimable + )); + + let (wrong_preimage, _) = random_preimage_and_hash(); + invoice_claim_expect_error( + node2_addr, + payment_hash.clone(), + wrong_preimage, + StatusCode::BAD_REQUEST, + "Invalid payment preimage", + "InvalidPaymentPreimage", + ) + .await; + + let first_settle = claim_hodl_invoice(node2_addr, payment_hash.clone(), preimage.clone()).await; + assert!(first_settle.changed); + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Claiming | InvoiceStatus::Succeeded + )); + + let cancel_while_settling_payload = CancelHodlInvoiceRequest { + payment_hash: payment_hash.clone(), + }; + let cancel_while_settling_res = reqwest::Client::new() + .post(format!("http://{node2_addr}/cancelhodlinvoice")) + .json(&cancel_while_settling_payload) + .send() + .await + .unwrap(); + if cancel_while_settling_res.status() == StatusCode::FORBIDDEN { + check_response_is_nok( + cancel_while_settling_res, + StatusCode::FORBIDDEN, + "Invoice settlement is in progress", + "InvoiceSettlingInProgress", + ) + .await; + } else if cancel_while_settling_res.status() == StatusCode::CONFLICT { + check_response_is_nok( + cancel_while_settling_res, + StatusCode::CONFLICT, + "Invoice is already claimed", + "InvoiceAlreadyClaimed", + ) + .await; + } else { + let status = cancel_while_settling_res.status(); + let body = cancel_while_settling_res.text().await.unwrap_or_default(); + panic!("expected 403 settling-in-progress or 409 already claimed, got {status}: {body}"); + } + + let claim_while_claiming_payload = ClaimHodlInvoiceRequest { + payment_hash: payment_hash.clone(), + payment_preimage: preimage.clone(), + }; + let claim_while_claiming_res = reqwest::Client::new() + .post(format!("http://{node2_addr}/claimhodlinvoice")) + .json(&claim_while_claiming_payload) + .send() + .await + .unwrap(); + if claim_while_claiming_res.status() == StatusCode::FORBIDDEN { + check_response_is_nok( + claim_while_claiming_res, + StatusCode::FORBIDDEN, + "Invoice settlement is in progress", + "InvoiceSettlingInProgress", + ) + .await; + } else if claim_while_claiming_res.status() == StatusCode::OK { + let _ = _check_response_is_ok(claim_while_claiming_res).await; + } else { + let status = claim_while_claiming_res.status(); + let body = claim_while_claiming_res.text().await.unwrap_or_default(); + panic!("expected 403 settling-in-progress or 200 already settled, got {status}: {body}"); + } + + let _ = wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + + let invoice_expiry_ts = decoded + .timestamp + .saturating_add(decoded.expiry_sec) + .saturating_add(1); + let wait_timeout = std::time::Duration::from_secs(decoded.expiry_sec.saturating_add(30)); + assert!( + wait_timeout > std::time::Duration::ZERO, + "invoice expiry wait timeout must be > 0" + ); + let target_ts = i128::from(invoice_expiry_ts); + let t_0 = std::time::Instant::now(); + loop { + let now_ts = i128::from(OffsetDateTime::now_utc().unix_timestamp()); + if now_ts >= target_ts { + break; + } + if t_0.elapsed() > wait_timeout { + panic!("invoice expiry did not pass in time (target: {target_ts}, current: {now_ts})"); + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + + let second_settle = + claim_hodl_invoice(node2_addr, payment_hash.clone(), preimage.clone()).await; + assert!(!second_settle.changed); + + let payee_payment = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Succeeded + )); + assert_eq!(payee_payment.asset_id, Some(asset_id.to_string())); + assert_eq!(payee_payment.asset_amount, Some(asset_payment_amount)); + + let payer_payment = + wait_for_ln_payment(node1_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; + assert_eq!(payer_payment.asset_id, Some(asset_id.to_string())); + assert_eq!(payer_payment.asset_amount, Some(asset_payment_amount)); + assert_eq!(payer_payment.preimage, Some(preimage)); + + wait_for_claimable_state(&test_dir_node2, &payment_hash, false) + .await + .unwrap_or_else(|err| panic!("wait for claimable entry to be removed: {err}")); + + 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; + + invoice_cancel_expect_error( + node2_addr, + payment_hash.clone(), + StatusCode::CONFLICT, + "Invoice is already claimed", + "InvoiceAlreadyClaimed", + ) + .await; +} diff --git a/src/test/invoice.rs b/src/test/invoice.rs index 5c4f17db..46758f92 100644 --- a/src/test/invoice.rs +++ b/src/test/invoice.rs @@ -21,6 +21,7 @@ async fn invoice() { expiry_sec: 900, asset_id: Some(asset_id.clone()), asset_amount: Some(1), + payment_hash: None, }; let res = reqwest::Client::new() .post(format!("http://{node1_addr}/lninvoice")) @@ -36,6 +37,7 @@ async fn invoice() { expiry_sec: 900, asset_id: Some(asset_id.clone()), asset_amount: Some(1), + payment_hash: None, }; let res = reqwest::Client::new() .post(format!("http://{node1_addr}/lninvoice")) @@ -51,6 +53,7 @@ async fn invoice() { expiry_sec: 900, asset_id: None, asset_amount: None, + payment_hash: None, }; let res = reqwest::Client::new() .post(format!("http://{node1_addr}/lninvoice")) @@ -99,6 +102,7 @@ async fn zero_amount_invoice() { expiry_sec: 900, asset_id: None, asset_amount: None, + payment_hash: None, }; let res = reqwest::Client::new() .post(format!("http://{node2_addr}/lninvoice")) @@ -179,6 +183,7 @@ async fn zero_amount_invoice() { expiry_sec: 900, asset_id: Some(asset_id.clone()), asset_amount: None, + payment_hash: None, }; let invoice_without_amount = reqwest::Client::new() .post(format!("http://{node2_addr}/lninvoice")) @@ -201,6 +206,7 @@ async fn zero_amount_invoice() { expiry_sec: 900, asset_id: Some(asset_id.clone()), asset_amount: Some(50), + payment_hash: None, }; let invoice_with_amount = reqwest::Client::new() .post(format!("http://{node2_addr}/lninvoice")) diff --git a/src/test/mod.rs b/src/test/mod.rs index 18d63f12..fa759127 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -7,7 +7,8 @@ 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 std::collections::HashMap; use std::net::SocketAddr; @@ -20,12 +21,14 @@ use tokio::io::AsyncReadExt; use tokio::net::TcpListener; use tracing_test::traced_test; -use crate::error::APIErrorResponse; -use crate::ldk::FEE_RATE; +use crate::disk::{read_inbound_payment_info, INBOUND_PAYMENTS_FNAME}; +use crate::error::{APIError, APIErrorResponse}; +use crate::ldk::{InvoiceType, FEE_RATE}; use crate::routes::{ AddressResponse, AssetBalanceRequest, AssetBalanceResponse, AssetCFA, AssetIFA, AssetNIA, AssetUDA, Assignment, BackupRequest, BtcBalanceRequest, BtcBalanceResponse, - ChangePasswordRequest, Channel, CloseChannelRequest, ConnectPeerRequest, CreateUtxosRequest, + CancelHodlInvoiceRequest, ChangePasswordRequest, Channel, ClaimHodlInvoiceRequest, + ClaimHodlInvoiceResponse, CloseChannelRequest, ConnectPeerRequest, CreateUtxosRequest, DecodeLNInvoiceRequest, DecodeLNInvoiceResponse, DecodeRGBInvoiceRequest, DecodeRGBInvoiceResponse, DisconnectPeerRequest, EmptyResponse, FailTransfersRequest, FailTransfersResponse, GetAssetMediaRequest, GetAssetMediaResponse, GetChannelIdRequest, @@ -38,13 +41,16 @@ use crate::routes::{ ListPaymentsResponse, ListPeersResponse, ListSwapsResponse, ListTransactionsRequest, ListTransactionsResponse, ListTransfersRequest, ListTransfersResponse, ListUnspentsRequest, ListUnspentsResponse, MakerExecuteRequest, MakerInitRequest, MakerInitResponse, - NetworkInfoResponse, NodeInfoResponse, OpenChannelRequest, OpenChannelResponse, Payment, Peer, - PostAssetMediaResponse, Recipient, RefreshRequest, RestoreRequest, RevokeTokenRequest, - RgbInvoiceRequest, RgbInvoiceResponse, SendBtcRequest, SendBtcResponse, SendPaymentRequest, - SendPaymentResponse, SendRgbRequest, SendRgbResponse, Swap, SwapStatus, TakerRequest, - Transaction, Transfer, UnlockRequest, Unspent, WitnessData, + NetworkInfoResponse, NodeInfoResponse, OpenChannelRequest, OpenChannelResponse, Payment, + PaymentType, Peer, PostAssetMediaResponse, Recipient, RefreshRequest, RestoreRequest, + RevokeTokenRequest, RgbInvoiceRequest, RgbInvoiceResponse, SendBtcRequest, SendBtcResponse, + SendPaymentRequest, SendPaymentResponse, SendRgbRequest, SendRgbResponse, 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, hex_str_to_vec, ELECTRUM_URL_REGTEST, PROXY_ENDPOINT_LOCAL}; use super::*; @@ -320,6 +326,18 @@ async fn btc_balance(node_address: SocketAddr) -> BtcBalanceResponse { .unwrap() } +async fn cancel_hodl_invoice(node_address: SocketAddr, payment_hash: String) { + println!("cancelling HODL invoice {payment_hash} on node {node_address}"); + let payload = CancelHodlInvoiceRequest { 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 change_password(node_address: SocketAddr, old_password: &str, new_password: &str) { println!("changing password for node {node_address}"); let payload = ChangePasswordRequest { @@ -868,6 +886,7 @@ async fn list_payments(node_address: SocketAddr) -> Vec { .unwrap() .payments } + async fn get_payment(node_address: SocketAddr, payment_hash: &str) -> Payment { println!("getting payment for node {node_address}"); let payload = GetPaymentRequest { @@ -991,15 +1010,57 @@ async fn ln_invoice( asset_id: Option<&str>, asset_amount: Option, expiry_sec: u32, +) -> LNInvoiceResponse { + ln_invoice_with_type( + node_address, + amt_msat, + asset_id, + asset_amount, + expiry_sec, + None, + InvoiceType::AutoClaim, + ) + .await +} + +async fn ln_invoice_hodl( + node_address: SocketAddr, + amt_msat: Option, + asset_id: Option<&str>, + asset_amount: Option, + expiry_sec: u32, + payment_hash: Option, +) -> LNInvoiceResponse { + ln_invoice_with_type( + node_address, + amt_msat, + asset_id, + asset_amount, + expiry_sec, + payment_hash, + InvoiceType::Hodl, + ) + .await +} + +async fn ln_invoice_with_type( + node_address: SocketAddr, + amt_msat: Option, + asset_id: Option<&str>, + asset_amount: Option, + expiry_sec: u32, + payment_hash: Option, + invoice_type: InvoiceType, ) -> LNInvoiceResponse { println!( - "generating invoice for {asset_amount:?} of asset {asset_id:?} for node {node_address}" + "generating {invoice_type:?} invoice for {asset_amount:?} of asset {asset_id:?} for node {node_address}" ); let payload = LNInvoiceRequest { amt_msat: Some(amt_msat.unwrap_or(3000000)), expiry_sec, asset_id: asset_id.map(|a| a.to_string()), asset_amount, + payment_hash, }; let res = reqwest::Client::new() .post(format!("http://{node_address}/lninvoice")) @@ -1367,6 +1428,14 @@ async fn post_asset_media(node_address: SocketAddr, file_path: &str) -> String { .digest } +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 refresh_transfers(node_address: SocketAddr) { println!("refreshing transfers for node {node_address}"); let payload = RefreshRequest { skip_sync: false }; @@ -1586,6 +1655,29 @@ async fn send_payment_with_status( .await } +async fn claim_hodl_invoice( + node_address: SocketAddr, + payment_hash: String, + payment_preimage: String, +) -> ClaimHodlInvoiceResponse { + println!("claiming HODL invoice {payment_hash} on node {node_address}"); + let payload = ClaimHodlInvoiceRequest { + payment_hash, + payment_preimage, + }; + let res = reqwest::Client::new() + .post(format!("http://{node_address}/claimhodlinvoice")) + .json(&payload) + .send() + .await + .unwrap(); + _check_response_is_ok(res) + .await + .json::() + .await + .unwrap() +} + async fn shutdown(node_sockets: &[SocketAddr]) { // shutdown nodes for node_address in node_sockets { @@ -1937,6 +2029,7 @@ mod concurrent_btc_payments; mod concurrent_openchannel; mod fail_transfers; mod getchannelid; +mod hodl_invoice; mod htlc_amount_checks; mod inflate; mod init; diff --git a/src/test/payment.rs b/src/test/payment.rs index 402d977c..ff3646e6 100644 --- a/src/test/payment.rs +++ b/src/test/payment.rs @@ -313,7 +313,7 @@ async fn same_invoice_twice_and_expired_inbound_payments() { let pending_before: Vec<_> = payments_before .iter() .filter(|p| { - p.inbound + p.payment_type == PaymentType::InboundAutoClaim && matches!(p.status, HTLCStatus::Pending) && [ decoded1.payment_hash.as_str(), @@ -365,7 +365,7 @@ async fn same_invoice_twice_and_expired_inbound_payments() { let still_pending: Vec<_> = payments_after .iter() .filter(|p| { - p.inbound + p.payment_type == PaymentType::InboundAutoClaim && matches!(p.status, HTLCStatus::Pending) && [ decoded1.payment_hash.as_str(), diff --git a/src/utils.rs b/src/utils.rs index c214f086..0eea6a4a 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; @@ -445,3 +448,29 @@ pub(crate) fn get_route( route.ok() } + +pub(crate) fn validate_and_parse_payment_hash( + payment_hash_str: &str, +) -> Result { + let payment_hash_vec = hex_str_to_vec(payment_hash_str); + if payment_hash_vec.is_none() || payment_hash_vec.as_ref().unwrap().len() != 32 { + return Err(APIError::InvalidPaymentHash(payment_hash_str.to_string())); + } + Ok(PaymentHash(payment_hash_vec.unwrap().try_into().unwrap())) +} + +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); + if preimage_vec.is_none() || preimage_vec.as_ref().unwrap().len() != 32 { + return Err(APIError::InvalidPaymentPreimage); + } + let preimage = PaymentPreimage(preimage_vec.unwrap().try_into().unwrap()); + let computed_hash = PaymentHash(Sha256::hash(&preimage.0).to_byte_array()); + if computed_hash != *payment_hash { + return Err(APIError::InvalidPaymentPreimage); + } + Ok(preimage) +}