diff --git a/README.md b/README.md index 06a59f4e..e0d8096c 100644 --- a/README.md +++ b/README.md @@ -232,9 +232,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 cfb5942c..27e9c1e5 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/CancelHodlInvoiceRequest' + 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/EmptyResponse' /closechannel: post: tags: @@ -716,6 +752,8 @@ paths: description: >- Get a LN invoice to receive a payment. The description and description_hash fields are mutually exclusive. + Provide `payment_hash` to create a HODL invoice. + Provide `min_final_cltv_expiry_delta` to request an explicit inbound final CLTV policy. requestBody: content: application/json: @@ -1543,6 +1581,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: @@ -1665,6 +1711,18 @@ 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 CloseChannelRequest: type: object required: @@ -1731,6 +1789,7 @@ components: - payment_hash - payment_secret - network + - min_final_cltv_expiry_delta properties: amt_msat: type: @@ -1776,6 +1835,9 @@ components: example: 0343851df9e0e8aff0c10b3498ce723ff4c9b4a855e6c8819adcafbbb3e24ea2af network: $ref: '#/components/schemas/BitcoinNetwork' + min_final_cltv_expiry_delta: + type: integer + example: 144 DecodeRGBInvoiceRequest: type: object required: @@ -1986,10 +2048,13 @@ components: type: object required: - payment_hash + - payment_type properties: payment_hash: type: string example: 5ca5d81b482b4015e7b14df7a27fe0a38c226273604ffd3b008b752571811938 + payment_type: + $ref: '#/components/schemas/PaymentType' GetPaymentResponse: type: object required: @@ -2020,7 +2085,10 @@ components: type: string enum: - Pending + - Claimable + - Claiming - Succeeded + - Cancelled - Failed IndexerProtocol: type: string @@ -2083,7 +2151,10 @@ components: type: string enum: - Pending + - Claimable + - Claiming - Succeeded + - Cancelled - Failed - Expired InvoiceStatusRequest: @@ -2512,6 +2583,14 @@ components: - string - 'null' example: 5ca5d81b482b4015e7b14df7a27fe0a38c226273604ffd3b008b752571811938 + payment_hash: + type: + - string + - 'null' + example: 3febfae1e68b190c15461f4c2a3290f9af1dae63fd7d620d2bd61601869026cd + min_final_cltv_expiry_delta: + type: integer + example: 144 LNInvoiceResponse: type: object required: @@ -2762,7 +2841,7 @@ components: type: object required: - payment_hash - - inbound + - payment_type - status - created_at - updated_at @@ -2786,9 +2865,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: @@ -2813,6 +2891,12 @@ components: - string - 'null' example: 5ca5d81b482b4015e7b14df7a27fe0a38c226273604ffd3b008b752571811938 + PaymentType: + type: string + enum: + - Outbound + - InboundAutoClaim + - InboundHodl Peer: type: object required: diff --git a/src/error.rs b/src/error.rs index da56b293..98336527 100644 --- a/src/error.rs +++ b/src/error.rs @@ -55,6 +55,9 @@ pub enum APIError { #[error("Cannot call other APIs while node is changing state")] ChangingState, + #[error("HTLC claim deadline exceeded")] + ClaimDeadlineExceeded, + #[error("Consignment file is empty")] ConsignmentFileEmpty, @@ -197,6 +200,9 @@ pub enum APIError { #[error("Invalid payment hash: {0}")] InvalidPaymentHash(String), + #[error("Invalid payment preimage")] + InvalidPaymentPreimage, + #[error("Invalid payment secret")] InvalidPaymentSecret, @@ -248,6 +254,21 @@ pub enum APIError { #[error("Invalid transport endpoints: {0}")] InvalidTransportEndpoints(String), + #[error("Invoice is already claimed")] + InvoiceAlreadyClaimed, + + #[error("Invoice cannot be cancelled")] + InvoiceNotCancellable, + + #[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), @@ -291,6 +312,9 @@ pub enum APIError { #[error("Output below the dust limit")] OutputBelowDustLimit, + #[error("Payment hash already used")] + PaymentHashAlreadyUsed, + #[error("Payment not found: {0}")] PaymentNotFound(String), @@ -473,6 +497,7 @@ impl APIError { APIError::AnchorsRequired | APIError::CannotProvideOutOfBandAck(_) | APIError::CannotProvideOutOfBandConsignment(_) + | APIError::ClaimDeadlineExceeded | APIError::ConsignmentFileEmpty | APIError::ConsignmentFileNotProvided | APIError::ConsignmentNotFound @@ -503,6 +528,7 @@ impl APIError { | APIError::InvalidOnionData(_) | APIError::InvalidPassword(_) | APIError::InvalidPaymentHash(_) + | APIError::InvalidPaymentPreimage | APIError::InvalidPaymentSecret | APIError::InvalidPeerInfo(_) | APIError::InvalidPrecision(_) @@ -522,6 +548,7 @@ impl APIError { | APIError::MediaFileNotProvided | APIError::MissingSwapPaymentPreimage | APIError::OutputBelowDustLimit + | APIError::PaymentHashAlreadyUsed | APIError::UnsupportedBackupVersion { .. } => StatusCode::BAD_REQUEST, APIError::WrongPassword => StatusCode::UNAUTHORIZED, APIError::AllocationsAlreadyAvailable @@ -542,6 +569,9 @@ impl APIError { | APIError::InsufficientFunds(_) | APIError::InvalidIndexer(_) | APIError::InvalidProxyProtocol(_) + | APIError::InvoiceNotCancellable + | APIError::InvoiceNotHodl + | APIError::InvoiceSettlingInProgress | APIError::LockedNode | APIError::MaxFeeExceeded(_) | APIError::MinFeeNotMet(_) @@ -564,6 +594,8 @@ impl APIError { APIError::FailedBitcoindConnection(_) | APIError::NetworkMismatch(_, _) => { StatusCode::FORBIDDEN } + APIError::InvoiceAlreadyClaimed => StatusCode::CONFLICT, + APIError::InvoiceNotClaimable => StatusCode::NOT_FOUND, APIError::Network(_) | APIError::NoValidTransportEndpoint => { StatusCode::SERVICE_UNAVAILABLE } diff --git a/src/ldk.rs b/src/ldk.rs index 4508a932..ff782071 100644 --- a/src/ldk.rs +++ b/src/ldk.rs @@ -13,8 +13,9 @@ use lightning::chain::Confirm; use lightning::chain::{chainmonitor, ChannelMonitorUpdateStatus}; use lightning::chain::{BestBlock, Filter}; use lightning::events::bump_transaction::{BumpTransactionEventHandler, Wallet}; -use lightning::events::{Event, PaymentFailureReason, PaymentPurpose, ReplayEvent}; -use lightning::impl_writeable_tlv_based; +use lightning::events::{ + Event, HTLCHandlingFailureType, PaymentFailureReason, PaymentPurpose, ReplayEvent, +}; use lightning::ln::channelmanager::{self, PaymentId, RecentPaymentDetails}; use lightning::ln::channelmanager::{ ChainParameters, ChannelManagerReadArgs, SimpleArcChannelManager, @@ -28,9 +29,9 @@ use lightning::onion_message::messenger::{ DefaultMessageRouter, OnionMessenger as LdkOnionMessenger, }; use lightning::rgb_utils::{ - get_rgb_channel_info_pending, is_channel_rgb, parse_rgb_payment_info, read_rgb_transfer_info, - update_rgb_channel_amount, write_rgb_channel_info, INDEXER_URL_FNAME, STATIC_BLINDING, - WALLET_MASTER_FINGERPRINT_FNAME, + get_rgb_channel_info_pending, get_rgb_payment_info_path, is_channel_rgb, + parse_rgb_payment_info, read_rgb_transfer_info, update_rgb_channel_amount, + write_rgb_channel_info, INDEXER_URL_FNAME, STATIC_BLINDING, WALLET_MASTER_FINGERPRINT_FNAME, }; use lightning::routing::gossip; use lightning::routing::gossip::{NodeId, P2PGossipSync}; @@ -51,6 +52,7 @@ use lightning::util::persist::{ }; use lightning::util::ser::{ReadableArgs, Writeable}; use lightning::util::sweep as ldk_sweep; +use lightning::{impl_writeable_tlv_based, impl_writeable_tlv_based_enum}; use lightning_background_processor::{process_events_async, GossipSync, NO_LIQUIDITY_MANAGER}; #[cfg(feature = "block-sync")] use lightning_block_sync::{init, poll, SpvClient, UnboundedCache}; @@ -165,6 +167,18 @@ pub(crate) static HELD_PAYMENT_CLAIMABLE_COUNT: AtomicUsize = AtomicUsize::new(0 #[cfg(test)] pub(crate) static FORCE_PUSH_ASSET_AMOUNT_ON_NODE: Mutex> = Mutex::new(None); +// Test-only: make a HODL claim fail backwards after its persisted status has become Claiming. +#[cfg(test)] +pub(crate) static FAIL_HODL_CLAIM_ON_NODE: Mutex> = Mutex::new(None); + +// Test-only: hold a HODL claim after persisting Claiming but before calling claim_funds. +#[cfg(test)] +pub(crate) static HOLD_HODL_CLAIM_ON_NODE: Mutex> = Mutex::new(None); + +// Test-only: whether a HODL claim is currently held by HOLD_HODL_CLAIM_ON_NODE. +#[cfg(test)] +pub(crate) static HODL_CLAIM_HELD: AtomicBool = AtomicBool::new(false); + // Test-only: whether the given override targets the node we are running as #[cfg(test)] pub(crate) fn node_override_matches( @@ -178,6 +192,17 @@ pub(crate) fn node_override_matches( .is_some_and(|id| *id == our_node_id) } +#[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, @@ -197,6 +222,10 @@ pub(crate) struct PaymentInfo { pub(crate) expires_at: Option, pub(crate) description: Option, pub(crate) description_hash: Option<[u8; 32]>, + pub(crate) claim_deadline_height: Option, + pub(crate) invoice_type: Option, + pub(crate) asset_id: Option, + pub(crate) asset_amount: Option, } impl_writeable_tlv_based!(PaymentInfo, { @@ -210,6 +239,10 @@ impl_writeable_tlv_based!(PaymentInfo, { (14, expires_at, option), (16, description, option), (18, description_hash, option), + (20, claim_deadline_height, option), + (22, invoice_type, option), + (24, asset_id, option), + (26, asset_amount, option), }); pub(crate) struct InboundPaymentInfoStorage { @@ -337,6 +370,26 @@ impl UnlockedAppState { Ok(()) } + pub(crate) fn fail_htlc_backwards_and_update_inbound_payment( + &self, + payment_hash: PaymentHash, + status: HTLCStatus, + preimage: Option, + secret: Option, + ) { + self.channel_manager.fail_htlc_backwards(&payment_hash); + self.upsert_inbound_payment( + payment_hash, + status, + preimage, + secret, + 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; @@ -358,26 +411,62 @@ 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 claim_deadline = payment_info + .claim_deadline_height + .expect("claimable payment must have a claim deadline"); + if height >= claim_deadline { + claimables_to_fail.push((*payment_hash, claim_deadline)); + } } + _ => {} } } - let payments = inbound.payments.clone(); + + if claimables_to_fail.is_empty() { + let payments = inbound.payments.clone(); + if failed { + self.save_inbound_payments(inbound); + } + return payments; + } + if failed { self.save_inbound_payments(inbound); + } else { + drop(inbound); } - payments + + for (payment_hash, claim_deadline) in claimables_to_fail { + tracing::info!( + "Expiring claimable payment {:?} (deadline: {})", + payment_hash, + claim_deadline + ); + self.fail_htlc_backwards_and_update_inbound_payment( + payment_hash, + HTLCStatus::Failed, + None, + None, + ); + } + + self.inbound_payments() } pub(crate) fn inbound_payments(&self) -> LdkHashMap { @@ -388,7 +477,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(); @@ -400,7 +489,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, @@ -408,18 +498,23 @@ 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) { Entry::Occupied(mut e) => { let payment_info = e.get_mut(); payment_info.status = status; - payment_info.preimage = preimage; - payment_info.secret = secret; + payment_info.preimage = preimage.or(payment_info.preimage); + payment_info.secret = secret.or(payment_info.secret); if amt_msat.is_some() { 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(); @@ -434,6 +529,10 @@ impl UnlockedAppState { expires_at: None, description: None, description_hash: None, + claim_deadline_height, + invoice_type, + asset_id: None, + asset_amount: None, }); } } @@ -638,6 +737,24 @@ fn find_and_update_rgb_chan_amt(ldk_data_dir: &Path, payment_hash: &PaymentHash, } } +pub(crate) fn clear_rgb_payment_pending(payment_hash: &PaymentHash, ldk_data_dir: &Path) { + let payment_path = get_rgb_payment_info_path(payment_hash, ldk_data_dir, false); + let extension = payment_path + .extension() + .expect("RGB payment info path has an extension") + .to_string_lossy(); + + let pending_payment_path = payment_path.with_extension(format!("{extension}_pending")); + if let Err(err) = fs::remove_file(&pending_payment_path) { + if err.kind() != std::io::ErrorKind::NotFound { + tracing::warn!( + "Unable to remove RGB pending payment artifact {}: {err}", + pending_payment_path.display() + ); + } + } +} + // Handle an rgb-lib error that happened while preparing a channel funding transaction in // FundingGenerationReady. Returns the value to propagate from the event handler: `Err(ReplayEvent)` // to retry the event (for transient network errors), or `Ok(())` after force-closing the channel @@ -1031,7 +1148,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: _, @@ -1069,21 +1186,114 @@ async fn handle_ldk_events( tracing::info!("TEST: resuming PaymentClaimable for {}", payment_hash); } } - 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 invoice = unlocked_state + .get_inbound_payments() + .payments + .get(&payment_hash) + .cloned() + .expect("Missing inbound payment state for claimable payment"); + + (payment_preimage, Some(payment_secret), invoice) + } }; - unlocked_state - .channel_manager - .claim_funds(payment_preimage.unwrap()); + + if let (Some(expected_asset_id), Some(expected_asset_amount)) = + (invoice.asset_id.as_deref(), invoice.asset_amount) + { + let inbound_rgb_payment_path = get_rgb_payment_info_path( + &payment_hash, + &PathBuf::from(&static_state.ldk_data_dir), + true, + ); + let rgb_payment = inbound_rgb_payment_path + .exists() + .then(|| parse_rgb_payment_info(&inbound_rgb_payment_path)); + let asset_id_matches = rgb_payment + .as_ref() + .is_some_and(|payment| payment.contract_id.to_string() == expected_asset_id); + let asset_amount_is_sufficient = rgb_payment + .as_ref() + .is_some_and(|payment| payment.amount >= expected_asset_amount); + + if !asset_id_matches || !asset_amount_is_sufficient { + let invalid_fields = match (asset_id_matches, asset_amount_is_sufficient) { + (false, false) => "asset ID and asset amount", + (false, true) => "asset ID", + (true, false) => "asset amount", + (true, true) => unreachable!(), + }; + let received_asset_id = rgb_payment + .as_ref() + .map(|payment| payment.contract_id.to_string()); + let received_asset_amount = rgb_payment.as_ref().map(|payment| payment.amount); + tracing::warn!( + "Received invalid {invalid_fields} for invoice with payment hash {payment_hash}: expected asset ID {expected_asset_id} and asset amount at least {expected_asset_amount}; received asset ID {received_asset_id:?} and asset amount {received_asset_amount:?}", + ); + unlocked_state.fail_htlc_backwards_and_update_inbound_payment( + payment_hash, + HTLCStatus::Failed, + payment_preimage, + payment_secret, + ); + return Ok(()); + } + } + + match invoice.invoice_type.unwrap_or(InvoiceType::AutoClaim) { + InvoiceType::AutoClaim => { + unlocked_state + .channel_manager + .claim_funds(payment_preimage.unwrap()); + } + InvoiceType::Hodl => { + 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, + payment_preimage, + payment_secret, + ); + return Ok(()); + } + } + + 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, @@ -1150,6 +1360,8 @@ async fn handle_ldk_events( payment_secret, Some(amount_msat), receiver_node_id.unwrap(), + None, + None, ); } } @@ -1243,6 +1455,7 @@ async fn handle_ldk_events( .. } => { if let Some(hash) = payment_hash { + clear_rgb_payment_pending(&hash, &static_state.ldk_data_dir); tracing::error!( "EVENT: Failed to send payment to payment ID {}, payment hash {}: {:?}", payment_id, @@ -1289,6 +1502,7 @@ async fn handle_ldk_events( inbound_amount_forwarded_rgb, payment_hash, } => { + clear_rgb_payment_pending(&payment_hash, &static_state.ldk_data_dir); let prev_channel_id_str = prev_channel_id.expect("prev_channel_id").to_string(); let next_channel_id_str = next_channel_id.expect("next_channel_id").to_string(); @@ -1381,7 +1595,29 @@ async fn handle_ldk_events( ); } } - Event::HTLCHandlingFailed { .. } => {} + Event::HTLCHandlingFailed { + prev_channel_id, + failure_type, + failure_reason, + } => { + tracing::warn!( + "EVENT: HTLC handling failed on channel {prev_channel_id}: type {failure_type:?}, reason {failure_reason:?}" + ); + + if let HTLCHandlingFailureType::Receive { payment_hash } = failure_type { + let mut inbound = unlocked_state.get_inbound_payments(); + if let Some(payment_info) = inbound.payments.get_mut(&payment_hash) { + if payment_info.status == HTLCStatus::Claiming { + payment_info.status = HTLCStatus::Failed; + payment_info.updated_at = get_current_timestamp(); + unlocked_state.save_inbound_payments(inbound); + tracing::warn!( + "Marked inbound HODL payment {payment_hash} as failed after HTLC handling failure" + ); + } + } + } + } Event::SpendableOutputs { outputs, channel_id, diff --git a/src/main.rs b/src/main.rs index 71f810d3..ca7cf85c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,17 +59,17 @@ use crate::error::AppError; use crate::ldk::stop_ldk; use crate::rgb_file_transfer::MAX_CONSIGNMENT_SIZE; 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, decode_swapstring, disconnect_peer, estimate_fee, - fail_transfers, get_asset_media, get_channel_id, get_consignment, 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, provide_out_of_band_ack, - provide_out_of_band_consignment, 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, decode_swapstring, + disconnect_peer, estimate_fee, fail_transfers, get_asset_media, get_channel_id, + get_consignment, 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, provide_out_of_band_ack, provide_out_of_band_consignment, 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}; @@ -157,9 +157,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 4908dc71..8195e901 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -80,12 +80,16 @@ use tokio::{ }; #[cfg(test)] -use crate::ldk::{node_override_matches, FORCE_PUSH_ASSET_AMOUNT_ON_NODE}; +use crate::ldk::{ + node_override_matches, FAIL_HODL_CLAIM_ON_NODE, FORCE_PUSH_ASSET_AMOUNT_ON_NODE, + HODL_CLAIM_HELD, HOLD_HODL_CLAIM_ON_NODE, +}; use crate::swap::{SwapData, SwapInfo, SwapString}; use crate::utils::{ check_already_initialized, check_channel_id, check_password_strength, check_password_validity, description_fields, 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, + get_route, hex_str, hex_str_to_compressed_pubkey, hex_str_to_vec, + validate_and_parse_payment_hash, validate_and_parse_payment_preimage, UnlockedAppState, UserOnionMessageContents, }; use crate::{ @@ -95,7 +99,7 @@ use crate::{ use crate::{ disk::{self, CHANNEL_PEER_DATA}, error::APIError, - ldk::{PaymentInfo, UTXO_SIZE_SAT}, + ldk::{clear_rgb_payment_pending, InvoiceType, PaymentInfo, UTXO_SIZE_SAT}, utils::{ connect_peer_if_necessary, get_current_timestamp, no_cancel, parse_peer_info, AppState, }, @@ -441,6 +445,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, @@ -492,6 +501,12 @@ 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 CloseChannelRequest { pub(crate) channel_id: String, @@ -531,6 +546,7 @@ pub(crate) struct DecodeLNInvoiceResponse { pub(crate) payment_secret: String, pub(crate) payee_pubkey: Option, pub(crate) network: BitcoinNetwork, + pub(crate) min_final_cltv_expiry_delta: u64, } #[derive(Deserialize, Serialize)] @@ -645,6 +661,7 @@ pub(crate) struct GetConsignmentResponse { #[derive(Deserialize, Serialize)] pub(crate) struct GetPaymentRequest { pub(crate) payment_hash: String, + pub(crate) payment_type: PaymentType, } #[derive(Deserialize, Serialize)] @@ -667,7 +684,10 @@ pub(crate) struct GetSwapResponse { #[display(inner)] pub(crate) enum HTLCStatus { Pending, + Claimable, + Claiming, Succeeded, + Cancelled, Failed, } @@ -675,6 +695,9 @@ impl_writeable_tlv_based_enum!(HTLCStatus, (0, Pending) => {}, (1, Succeeded) => {}, (2, Failed) => {}, + (3, Claimable) => {}, + (4, Claiming) => {}, + (5, Cancelled) => {}, ); #[derive(Debug, Deserialize, Serialize)] @@ -719,7 +742,10 @@ pub(crate) struct InitResponse { #[derive(Clone, Copy, Deserialize, Serialize)] pub(crate) enum InvoiceStatus { Pending, + Claimable, + Claiming, Succeeded, + Cancelled, Failed, Expired, } @@ -894,6 +920,8 @@ pub(crate) struct LNInvoiceRequest { pub(crate) asset_amount: Option, pub(crate) description: Option, pub(crate) description_hash: Option, + pub(crate) payment_hash: Option, + pub(crate) min_final_cltv_expiry_delta: Option, } #[derive(Deserialize, Serialize)] @@ -1016,7 +1044,7 @@ pub(crate) struct Payment { 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, @@ -1026,6 +1054,20 @@ pub(crate) struct Payment { pub(crate) description_hash: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub(crate) enum PaymentType { + Outbound, + InboundAutoClaim, + InboundHodl, +} + +fn payment_type_from_invoice(invoice_type: Option<&InvoiceType>) -> PaymentType { + match invoice_type { + Some(InvoiceType::Hodl) => PaymentType::InboundHodl, + None | Some(InvoiceType::AutoClaim) => PaymentType::InboundAutoClaim, + } +} + #[derive(Clone, Deserialize, Serialize)] pub(crate) struct Peer { pub(crate) pubkey: String, @@ -1732,6 +1774,43 @@ 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::Claimable => {} + HTLCStatus::Succeeded => return Err(APIError::InvoiceAlreadyClaimed), + HTLCStatus::Claiming => return Err(APIError::InvoiceSettlingInProgress), + _ => return Err(APIError::InvoiceNotCancellable), + } + + unlocked_state.fail_htlc_backwards_and_update_inbound_payment( + payment_hash, + HTLCStatus::Cancelled, + None, + None, + ); + + Ok(Json(EmptyResponse {})) + }) + .await +} + pub(crate) async fn change_password( State(state): State>, WithRejection(Json(payload), _): WithRejection, APIError>, @@ -1773,6 +1852,87 @@ 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 (channel_manager, _payment_hash, preimage) = { + 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 => return Err(APIError::InvoiceAlreadyClaimed), + HTLCStatus::Claiming => return Err(APIError::InvoiceSettlingInProgress), + HTLCStatus::Claimable => {} + _ => return Err(APIError::InvoiceNotClaimable), + } + + let current_height = unlocked_state.channel_manager.current_best_block().height; + if let Some(deadline_height) = existing_payment_mut.claim_deadline_height { + if current_height >= deadline_height { + return Err(APIError::ClaimDeadlineExceeded); + } + } + + existing_payment_mut.status = HTLCStatus::Claiming; + existing_payment_mut.preimage = Some(preimage); + existing_payment_mut.updated_at = get_current_timestamp(); + unlocked_state.save_inbound_payments(inbound); + + ( + unlocked_state.channel_manager.clone(), + payment_hash, + preimage, + ) + }; + + #[cfg(test)] + let payment_hash = _payment_hash; + #[cfg(test)] + let node_id = channel_manager.get_our_node_id(); + + #[cfg(test)] + if node_override_matches(&HOLD_HODL_CLAIM_ON_NODE, node_id) { + HODL_CLAIM_HELD.store(true, std::sync::atomic::Ordering::SeqCst); + let held_at = std::time::Instant::now(); + while node_override_matches(&HOLD_HODL_CLAIM_ON_NODE, node_id) { + if held_at.elapsed() > Duration::from_secs(60) { + panic!("TEST: HODL claim held for too long"); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + + #[cfg(test)] + if node_override_matches(&FAIL_HODL_CLAIM_ON_NODE, node_id) { + tracing::info!("TEST: failing HODL claim backwards for payment {payment_hash}"); + channel_manager.fail_htlc_backwards(&payment_hash); + } else { + channel_manager.claim_funds(preimage); + } + + #[cfg(not(test))] + channel_manager.claim_funds(preimage); + + Ok(Json(EmptyResponse {})) + }) + .await +} + pub(crate) async fn close_channel( State(state): State>, WithRejection(Json(payload), _): WithRejection, APIError>, @@ -1926,6 +2086,7 @@ pub(crate) async fn decode_ln_invoice( payment_secret: hex_str(&invoice.payment_secret().0), payee_pubkey: invoice.payee_pub_key().map(|p| p.to_string()), network: invoice.network().into(), + min_final_cltv_expiry_delta: invoice.min_final_cltv_expiry_delta(), })) } @@ -2120,75 +2281,84 @@ 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(); + match payload.payment_type { + PaymentType::InboundAutoClaim | PaymentType::InboundHodl => { + let inbound_payments = unlocked_state.list_updated_inbound_payments(); - for (payment_hash, payment_info) in &inbound_payments { - if payment_hash == &requested_ph { - let rgb_payment_info_path_inbound = - get_rgb_payment_info_path(payment_hash, &state.static_state.ldk_data_dir, true); - - let (asset_amount, asset_id) = if rgb_payment_info_path_inbound.exists() { - let info = parse_rgb_payment_info(&rgb_payment_info_path_inbound); - (Some(info.amount), Some(info.contract_id.to_string())) - } else { - (None, None) - }; + for (payment_hash, payment_info) in &inbound_payments { + let payment_type = payment_type_from_invoice(payment_info.invoice_type.as_ref()); + if payment_hash == &requested_ph && payment_type == payload.payment_type { + let rgb_payment_info_path_inbound = get_rgb_payment_info_path( + payment_hash, + &state.static_state.ldk_data_dir, + true, + ); - return Ok(Json(GetPaymentResponse { - payment: Payment { - amt_msat: payment_info.amt_msat, - asset_amount, - asset_id, - payment_hash: hex_str(&payment_hash.0), - inbound: true, - status: payment_info.status, - created_at: payment_info.created_at, - updated_at: payment_info.updated_at, - payee_pubkey: payment_info.payee_pubkey.to_string(), - preimage: payment_info.preimage.map(|p| hex_str(&p.0)), - description: payment_info.description.clone(), - description_hash: payment_info.description_hash.map(|h| hex_str(&h)), - }, - })); + let (asset_amount, asset_id) = if rgb_payment_info_path_inbound.exists() { + let info = parse_rgb_payment_info(&rgb_payment_info_path_inbound); + (Some(info.amount), Some(info.contract_id.to_string())) + } else { + (None, None) + }; + + return Ok(Json(GetPaymentResponse { + payment: Payment { + amt_msat: payment_info.amt_msat, + asset_amount, + asset_id, + payment_hash: hex_str(&payment_hash.0), + payment_type, + status: payment_info.status, + created_at: payment_info.created_at, + updated_at: payment_info.updated_at, + payee_pubkey: payment_info.payee_pubkey.to_string(), + preimage: payment_info.preimage.map(|p| hex_str(&p.0)), + description: payment_info.description.clone(), + description_hash: payment_info.description_hash.map(|h| hex_str(&h)), + }, + })); + } + } } - } - - for (payment_id, payment_info) in &outbound_payments { - let payment_hash = &PaymentHash(payment_id.0); - if payment_hash == &requested_ph { - let rgb_payment_info_path_outbound = - get_rgb_payment_info_path(payment_hash, &state.static_state.ldk_data_dir, false); - - let (asset_amount, asset_id) = if rgb_payment_info_path_outbound.exists() { - let info = parse_rgb_payment_info(&rgb_payment_info_path_outbound); - (Some(info.amount), Some(info.contract_id.to_string())) - } else { - (None, None) - }; + PaymentType::Outbound => { + 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 rgb_payment_info_path_outbound = get_rgb_payment_info_path( + payment_hash, + &state.static_state.ldk_data_dir, + false, + ); - return Ok(Json(GetPaymentResponse { - payment: Payment { - amt_msat: payment_info.amt_msat, - asset_amount, - asset_id, - payment_hash: hex_str(&payment_hash.0), - inbound: false, - status: payment_info.status, - created_at: payment_info.created_at, - updated_at: payment_info.updated_at, - payee_pubkey: payment_info.payee_pubkey.to_string(), - preimage: payment_info.preimage.map(|p| hex_str(&p.0)), - description: payment_info.description.clone(), - description_hash: payment_info.description_hash.map(|h| hex_str(&h)), - }, - })); + let (asset_amount, asset_id) = if rgb_payment_info_path_outbound.exists() { + let info = parse_rgb_payment_info(&rgb_payment_info_path_outbound); + (Some(info.amount), Some(info.contract_id.to_string())) + } else { + (None, None) + }; + + return Ok(Json(GetPaymentResponse { + payment: Payment { + amt_msat: payment_info.amt_msat, + asset_amount, + asset_id, + payment_hash: hex_str(&payment_hash.0), + payment_type: PaymentType::Outbound, + status: payment_info.status, + created_at: payment_info.created_at, + updated_at: payment_info.updated_at, + payee_pubkey: payment_info.payee_pubkey.to_string(), + preimage: payment_info.preimage.map(|p| hex_str(&p.0)), + description: payment_info.description.clone(), + description_hash: payment_info.description_hash.map(|h| hex_str(&h)), + }, + })); + } + } } } @@ -2202,11 +2372,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(); @@ -2328,7 +2494,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), @@ -2509,6 +2678,10 @@ pub(crate) async fn keysend( expires_at: None, description: None, description_hash: None, + claim_deadline_height: None, + invoice_type: None, + asset_id: None, + asset_amount: None, }, )?; if let Some((contract_id, rgb_amount)) = rgb_payment { @@ -2539,6 +2712,7 @@ pub(crate) async fn keysend( } Err(e) => { tracing::error!("ERROR: failed to send payment: {:?}", e); + clear_rgb_payment_pending(&payment_hash, &state.static_state.ldk_data_dir); unlocked_state.update_outbound_payment_status(payment_id, HTLCStatus::Failed); HTLCStatus::Failed } @@ -2750,7 +2924,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.as_ref()), status: payment_info.status, created_at: payment_info.created_at, updated_at: payment_info.updated_at, @@ -2779,7 +2953,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, @@ -2976,8 +3150,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 }; @@ -3010,22 +3187,34 @@ pub(crate) async fn ln_invoice( (None, None) => Bolt11InvoiceDescription::Direct(Description::empty()), }; + let (requested_payment_hash, invoice_type) = 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), InvoiceType::Hodl) + } + None => (None, InvoiceType::AutoClaim), + }; + let invoice_params = Bolt11InvoiceParameters { amount_msats: payload.amt_msat, description, invoice_expiry_delta_secs: Some(payload.expiry_sec), + min_final_cltv_expiry_delta: payload.min_final_cltv_expiry_delta, + 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 = PaymentHash((*invoice.payment_hash()).to_byte_array()); let created_at = get_current_timestamp(); @@ -3043,6 +3232,10 @@ pub(crate) async fn ln_invoice( expires_at: Some(created_at + payload.expiry_sec as u64), description, description_hash, + claim_deadline_height: None, + invoice_type: Some(invoice_type), + asset_id: contract_id.map(|id| id.to_string()), + asset_amount: payload.asset_amount, }, ); @@ -3282,6 +3475,10 @@ pub(crate) async fn maker_execute( } Err(e) => { tracing::warn!("ERROR: failed to send payment: {:?}", e); + clear_rgb_payment_pending( + &swapstring.payment_hash, + &state.static_state.ldk_data_dir, + ); (HTLCStatus::Failed, Some(e)) } }; @@ -4075,6 +4272,10 @@ pub(crate) async fn send_payment( expires_at: None, description: None, description_hash: None, + claim_deadline_height: None, + invoice_type: None, + asset_id: None, + asset_amount: None, }, )?; @@ -4170,6 +4371,10 @@ pub(crate) async fn send_payment( expires_at: None, description, description_hash, + claim_deadline_height: None, + invoice_type: None, + asset_id: None, + asset_amount: None, }, )?; let payment_hash = PaymentHash(invoice.payment_hash().to_byte_array()); @@ -4201,6 +4406,10 @@ pub(crate) async fn send_payment( }, Err(e) => { tracing::error!("ERROR: failed to send payment: {:?}", e); + clear_rgb_payment_pending( + &payment_hash, + &state.static_state.ldk_data_dir, + ); status = HTLCStatus::Failed; unlocked_state.update_outbound_payment_status(payment_id, status); }, diff --git a/src/test/hodl_invoice.rs b/src/test/hodl_invoice.rs new file mode 100644 index 00000000..130d93fc --- /dev/null +++ b/src/test/hodl_invoice.rs @@ -0,0 +1,915 @@ +use super::*; + +const TEST_DIR_BASE: &str = "tmp/hodl_invoice/"; +const ASSET_PAYMENT_AMOUNT: u64 = 10; + +#[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_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, + payment_hash_hex.clone(), + ) + .await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + assert_eq!(decoded.payment_hash, payment_hash_hex); + + let defer_guard = if matches!(trigger, ExpiryTrigger::Time) { + let node2_pubkey = node_info(node2_addr).await.pubkey; + Some(defer_payment_claimable(&node2_pubkey)) + } else { + None + }; + + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + if defer_guard.is_some() { + wait_for_deferred_payment().await; + let target_ts = i128::from( + decoded + .timestamp + .saturating_add(decoded.expiry_sec) + .saturating_add(1), + ); + let timeout = std::time::Duration::from_secs(decoded.expiry_sec.saturating_add(60)); + let started_at = std::time::Instant::now(); + loop { + let now_ts = i128::from(OffsetDateTime::now_utc().unix_timestamp()); + if now_ts >= target_ts { + break; + } + if started_at.elapsed() > 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; + } + drop(defer_guard); + } else { + wait_for_inbound_payment_status(test_dir_node2, &payment_hash_hex, HTLCStatus::Claimable) + .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_inbound_payment_status(test_dir_node2, &payment_hash_hex, HTLCStatus::Failed) + .await + .unwrap_or_else(|err| panic!("wait for failed entry to persist: {err}")); + let payee_payment = + get_payment(node2_addr, &decoded.payment_hash, PaymentType::InboundHodl).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, PaymentType::InboundHodl).await; + assert_eq!(payee_payment_again.payment_type, PaymentType::InboundHodl); + assert_eq!(payee_payment_again.status, HTLCStatus::Failed); + 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::FORBIDDEN, + "Invoice cannot be cancelled", + "InvoiceNotCancellable", + ) + .await; +} + +fn set_inbound_rgb_payment_amount(test_dir: &str, payment_hash: &str, amount: u64) { + let payment_hash = validate_and_parse_payment_hash(payment_hash).unwrap(); + let ldk_data_dir = Path::new(test_dir).join(LDK_DIR); + let payment_info_path = get_rgb_payment_info_path(&payment_hash, &ldk_data_dir, true); + assert!( + payment_info_path.exists(), + "inbound RGB payment metadata must exist before it is modified" + ); + + let mut payment_info = parse_rgb_payment_info(&payment_info_path); + payment_info.amount = amount; + fs::write( + payment_info_path, + serde_json::to_string(&payment_info).unwrap(), + ) + .unwrap(); +} + +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_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; + } +} + +async fn wait_for_no_rgb_payment_pending_artifacts( + test_dir: &str, + payment_hash: &str, +) -> Result<(), APIError> { + let ldk_data_dir = Path::new(test_dir).join(LDK_DIR); + let pending_extension = ".outbound_pending"; + let t_0 = OffsetDateTime::now_utc(); + loop { + let pending_exists = std::fs::read_dir(&ldk_data_dir) + .map_err(|err| { + APIError::Unexpected(format!("cannot inspect RGB payment files: {err}")) + })? + .flatten() + .any(|entry| { + let file_name = entry.file_name(); + let file_name = file_name.to_string_lossy(); + file_name.contains(payment_hash) && file_name.ends_with(pending_extension) + }); + + if !pending_exists { + return Ok(()); + } + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 20.0 { + return Err(APIError::Unexpected(format!( + "RGB pending artifacts for {payment_hash} did not clear" + ))); + } + 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 cancel_hodl_invoice_btc_rgb() { + initialize(); + + let (node1_addr, node2_addr, test_dir_node1, test_dir_node2, asset_id) = + setup_two_nodes_with_asset_channel("cancel-hodl-btc-rgb", 10).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(); + + // create a HODL invoice + let LNInvoiceResponse { + invoice: hodl_invoice, + } = ln_invoice_hodl( + node2_addr, + Some(HTLC_MIN_MSAT), + Some(&asset_id), + Some(ASSET_PAYMENT_AMOUNT), + 120, + 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)); + + // attempt to cancel before the payment is claimable + invoice_cancel_expect_error( + node2_addr, + payment_hash.clone(), + StatusCode::FORBIDDEN, + "Invoice cannot be cancelled", + "InvoiceNotCancellable", + ) + .await; + assert!(matches!( + invoice_status(node2_addr, &hodl_invoice).await, + InvoiceStatus::Pending + )); + + // send the payment + let _ = send_payment_with_status(node1_addr, hodl_invoice.clone(), HTLCStatus::Pending).await; + + // wait for the payee's payment to become claimable + wait_for_inbound_payment_status(&test_dir_node2, &payment_hash, HTLCStatus::Claimable) + .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 the HODL invoice + cancel_hodl_invoice(node2_addr, payment_hash.clone()).await; + + wait_for_inbound_payment_status(&test_dir_node2, &payment_hash, HTLCStatus::Cancelled) + .await + .unwrap_or_else(|err| panic!("wait for cancelled payment to persist: {err}")); + wait_for_no_rgb_payment_pending_artifacts(&test_dir_node1, &payment_hash) + .await + .unwrap_or_else(|err| panic!("wait for sender RGB pending artifacts to clear: {err}")); + + // assert that the payer's payment failed + 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 that the payee's invoice was cancelled + assert!(matches!( + invoice_status(node2_addr, &hodl_invoice).await, + InvoiceStatus::Cancelled + )); + + // attempt to cancel the invoice again; cancellation is not idempotent + invoice_cancel_expect_error( + node2_addr, + payment_hash.clone(), + StatusCode::FORBIDDEN, + "Invoice cannot be cancelled", + "InvoiceNotCancellable", + ) + .await; + + // attempt to claim a non-claimable HODL invoice + invoice_claim_expect_error( + node2_addr, + payment_hash.clone(), + preimage, + StatusCode::NOT_FOUND, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; + + // assert that the payee's payment remains cancelled + 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)); + + // verify that RGB balances are unchanged + 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 (node1_addr, mut node2_addr, _test_dir_node1, test_dir_node2, asset_id) = + setup_two_nodes_with_asset_channel("claim-hodl-btc-rgb", 20).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(); + + // create a HODL invoice + let LNInvoiceResponse { invoice } = ln_invoice_hodl( + node2_addr, + Some(HTLC_MIN_MSAT), + Some(&asset_id), + Some(ASSET_PAYMENT_AMOUNT), + 120, + payment_hash.clone(), + ) + .await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + + // reject a second HODL invoice with the same payment hash + let duplicate_hash_payload = LNInvoiceRequest { + amt_msat: Some(10_000), + expiry_sec: 60, + asset_id: None, + asset_amount: None, + description: None, + description_hash: None, + payment_hash: Some(payment_hash.clone()), + min_final_cltv_expiry_delta: None, + }; + 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; + + // send the payment + let _ = send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + wait_for_inbound_payment_status(&test_dir_node2, &payment_hash, HTLCStatus::Claimable) + .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 + )); + + // restart the payee while the HODL invoice is still claimable and verify that the + // persisted inbound payment state is restored before claiming it + shutdown(&[node2_addr]).await; + let (restarted_node2_addr, _) = start_node(&test_dir_node2, NODE2_PEER_PORT + 20, true).await; + node2_addr = restarted_node2_addr; + + wait_for_inbound_payment_status(&test_dir_node2, &payment_hash, HTLCStatus::Claimable) + .await + .unwrap_or_else(|err| panic!("wait for claimable entry after restart: {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 + )); + + // reject an incorrect preimage + 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; + + // hold the claim after Claiming is persisted so competing requests deterministically observe + // the settlement-in-progress state. + let payee_pubkey = node_info(node2_addr).await.pubkey; + let hold_claim_guard = hold_hodl_claim(&payee_pubkey); + let claim_node = node2_addr; + let claim_hash = payment_hash.clone(); + let claim_preimage = preimage.clone(); + let claim_task = tokio::spawn(async move { + claim_hodl_invoice(claim_node, claim_hash, claim_preimage).await; + }); + wait_for_held_hodl_claim().await; + + // reject cancellation while settlement is deterministically held in progress + invoice_cancel_expect_error( + node2_addr, + payment_hash.clone(), + StatusCode::FORBIDDEN, + "Invoice settlement is in progress", + "InvoiceSettlingInProgress", + ) + .await; + + // a second claim must also be rejected while settlement is in progress + invoice_claim_expect_error( + node2_addr, + payment_hash.clone(), + preimage.clone(), + StatusCode::FORBIDDEN, + "Invoice settlement is in progress", + "InvoiceSettlingInProgress", + ) + .await; + + drop(hold_claim_guard); + claim_task.await.unwrap(); + wait_for_ln_payment(node2_addr, &payment_hash, HTLCStatus::Succeeded).await; + + // claiming an already settled HODL invoice is not idempotent + invoice_claim_expect_error( + node2_addr, + payment_hash.clone(), + preimage.clone(), + StatusCode::CONFLICT, + "Invoice is already claimed", + "InvoiceAlreadyClaimed", + ) + .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 + )); + assert_eq!(payee_payment.asset_id, Some(asset_id.to_string())); + assert_eq!(payee_payment.asset_amount, Some(ASSET_PAYMENT_AMOUNT)); + + // /getpayment must expose the preimage after the HODL invoice is claimed + let payee_payment_from_get = + get_payment(node2_addr, &decoded.payment_hash, PaymentType::InboundHodl).await; + assert_eq!( + payee_payment_from_get.payment_type, + PaymentType::InboundHodl + ); + assert_eq!(payee_payment_from_get.preimage, Some(preimage.clone())); + + 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_inbound_payment_status(&test_dir_node2, &payment_hash, HTLCStatus::Succeeded) + .await + .unwrap_or_else(|err| panic!("wait for claimed payment to persist as succeeded: {err}")); + + // verify RGB balances after successful payment + 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; + + // reject cancellation when the HODL invoice is already claimed + invoice_cancel_expect_error( + node2_addr, + payment_hash.clone(), + StatusCode::CONFLICT, + "Invoice is already claimed", + "InvoiceAlreadyClaimed", + ) + .await; +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn claim_hodl_invoice_handles_htlc_handling_failed() { + initialize(); + + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2, _asset_id) = + setup_two_nodes_with_asset_channel("claim-hodl-htlc-failed", 30).await; + let (preimage, payment_hash) = random_preimage_and_hash(); + + let invoice = ln_invoice_hodl( + node2_addr, + Some(HTLC_MIN_MSAT), + None, + None, + 120, + payment_hash.clone(), + ) + .await + .invoice; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + + send_payment_with_status(node1_addr, invoice.clone(), HTLCStatus::Pending).await; + wait_for_inbound_payment_status(&test_dir_node2, &payment_hash, HTLCStatus::Claimable) + .await + .unwrap_or_else(|err| panic!("wait for claimable entry to appear: {err}")); + let payee_pubkey = node_info(node2_addr).await.pubkey; + let fail_claim_guard = NodeOverrideGuard::set(&FAIL_HODL_CLAIM_ON_NODE, &payee_pubkey); + let preimage_for_retry = preimage.clone(); + claim_hodl_invoice(node2_addr, payment_hash.clone(), preimage).await; + drop(fail_claim_guard); + + let failed_payee = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Failed).await; + assert_eq!(failed_payee.status, HTLCStatus::Failed); + wait_for_inbound_payment_status(&test_dir_node2, &payment_hash, HTLCStatus::Failed) + .await + .unwrap_or_else(|err| panic!("wait for failed inbound entry: {err}")); + + let failed_payer = wait_for_ln_payment(node1_addr, &payment_hash, HTLCStatus::Failed).await; + assert_eq!(failed_payer.status, HTLCStatus::Failed); + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Failed + )); + + shutdown(&[node2_addr]).await; + let (restarted_node2_addr, _) = start_node(&test_dir_node2, NODE2_PEER_PORT + 30, true).await; + + wait_for_inbound_payment_status(&test_dir_node2, &payment_hash, HTLCStatus::Failed) + .await + .unwrap_or_else(|err| panic!("wait for failed entry after restart: {err}")); + invoice_claim_expect_error( + restarted_node2_addr, + payment_hash, + preimage_for_retry, + 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 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("expire-hodl-time-blocks", 40).await; + + // verify expiry triggered by wall-clock time + run_expire_hodl_invoice_case(node1_addr, node2_addr, &test_dir_node2, ExpiryTrigger::Time) + .await; + // verify expiry triggered by block height + 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 rgb_invoice_rejects_insufficient_asset_amount() { + initialize(); + + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2, asset_id) = + setup_two_nodes_with_asset_channel("rgb-amount-validation", 50).await; + let initial_node1_balance = asset_balance_offchain_outbound(node1_addr, &asset_id).await; + let initial_node2_balance = asset_balance_offchain_outbound(node2_addr, &asset_id).await; + let node2_pubkey = node_info(node2_addr).await.pubkey; + + // an RGB HODL invoice must fail instead of becoming Claimable when its + // persisted inbound RGB amount is below the invoice requirement + let (preimage, hodl_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, + hodl_payment_hash.clone(), + ) + .await; + + let defer_guard = defer_payment_claimable(&node2_pubkey); + send_payment_raw(node1_addr, hodl_invoice.clone()).await; + wait_for_deferred_payment().await; + set_inbound_rgb_payment_amount( + &test_dir_node2, + &hodl_payment_hash, + ASSET_PAYMENT_AMOUNT - 1, + ); + drop(defer_guard); + + let hodl_payee_failed = + wait_for_ln_payment(node2_addr, &hodl_payment_hash, HTLCStatus::Failed).await; + assert_eq!(hodl_payee_failed.status, HTLCStatus::Failed); + assert_eq!( + hodl_payee_failed.asset_amount, + Some(ASSET_PAYMENT_AMOUNT - 1) + ); + assert!(matches!( + invoice_status(node2_addr, &hodl_invoice).await, + InvoiceStatus::Failed + )); + let hodl_payer_failed = + wait_for_ln_payment(node1_addr, &hodl_payment_hash, HTLCStatus::Failed).await; + assert_eq!(hodl_payer_failed.status, HTLCStatus::Failed); + invoice_claim_expect_error( + node2_addr, + hodl_payment_hash, + preimage, + StatusCode::NOT_FOUND, + "No claimable HTLC found for this invoice", + "InvoiceNotClaimable", + ) + .await; + + // the same validation runs before auto-claiming a regular RGB invoice + let LNInvoiceResponse { invoice } = ln_invoice( + node2_addr, + Some(HTLC_MIN_MSAT), + Some(&asset_id), + Some(ASSET_PAYMENT_AMOUNT), + 120, + ) + .await; + let decoded = decode_ln_invoice(node1_addr, &invoice).await; + + let defer_guard = defer_payment_claimable(&node2_pubkey); + send_payment_raw(node1_addr, invoice.clone()).await; + wait_for_deferred_payment().await; + set_inbound_rgb_payment_amount( + &test_dir_node2, + &decoded.payment_hash, + ASSET_PAYMENT_AMOUNT - 1, + ); + drop(defer_guard); + + let auto_claim_payee_failed = + wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Failed).await; + assert_eq!(auto_claim_payee_failed.status, HTLCStatus::Failed); + assert_eq!( + auto_claim_payee_failed.asset_amount, + Some(ASSET_PAYMENT_AMOUNT - 1) + ); + assert!(matches!( + invoice_status(node2_addr, &invoice).await, + InvoiceStatus::Failed + )); + let auto_claim_payer_failed = + wait_for_ln_payment(node1_addr, &decoded.payment_hash, HTLCStatus::Failed).await; + assert_eq!(auto_claim_payer_failed.status, HTLCStatus::Failed); + + wait_for_ln_balance(node1_addr, &asset_id, initial_node1_balance).await; + wait_for_ln_balance(node2_addr, &asset_id, initial_node2_balance).await; +} + +#[serial_test::serial] +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[traced_test] +async fn use_same_hodl_invoice_hash_for_inbound_rgb_and_outbound_btc() { + initialize(); + + let test_dir_suffix = "swap-hodl-same-hash"; + let (node1_addr, node2_addr, _test_dir_node1, test_dir_node2, asset_id) = + setup_two_nodes_with_asset_channel(test_dir_suffix, 60).await; + let test_dir_node3 = format!("{TEST_DIR_BASE}{test_dir_suffix}/node3"); + let node3_port = NODE3_PEER_PORT + 60; + let (node3_addr, _) = start_node(&test_dir_node3, node3_port, false).await; + fund_and_create_utxos(node3_addr, None).await; + + let node3_pubkey = node_info(node3_addr).await.pubkey; + open_channel_with_retry( + node2_addr, + &node3_pubkey, + Some(node3_port), + Some(500_000), + Some(0), + None, + None, + None, + 5, + ) + .await; + + let (_inbound_preimage, payment_hash) = random_preimage_and_hash(); + + // create a HODL invoice for node2 to receive RGB assets + let inbound_invoice = ln_invoice_hodl( + node2_addr, + Some(HTLC_MIN_MSAT), + Some(&asset_id), + Some(ASSET_PAYMENT_AMOUNT), + 120, + payment_hash.clone(), + ) + .await + .invoice; + + // send the payment from node1 to node2 + send_payment_with_status(node1_addr, inbound_invoice, HTLCStatus::Pending).await; + wait_for_inbound_payment_status(&test_dir_node2, &payment_hash, HTLCStatus::Claimable) + .await + .unwrap_or_else(|err| panic!("wait for inbound RGB payment to become claimable: {err}")); + + let inbound_rgb_path = Path::new(&test_dir_node2) + .join(LDK_DIR) + .join(format!("{payment_hash}.inbound")); + assert!( + inbound_rgb_path.exists(), + "expected inbound RGB metadata at {}", + inbound_rgb_path.display() + ); + + // create a HODL invoice for node3 to receive BTC + let outbound_invoice = ln_invoice_hodl( + node3_addr, + Some(HTLC_MIN_MSAT), + None, + None, + 120, + payment_hash, + ) + .await + .invoice; + + // attempt to send the BTC payment from node2 to node3 + let response = reqwest::Client::new() + .post(format!("http://{node2_addr}/sendpayment")) + .json(&SendPaymentRequest { + invoice: outbound_invoice, + amt_msat: None, + asset_id: None, + asset_amount: None, + }) + .send() + .await + .expect("sendpayment request should reach the node"); + + assert!(response.status().is_success()); +} diff --git a/src/test/invoice.rs b/src/test/invoice.rs index 5ee2de8a..733b8fe3 100644 --- a/src/test/invoice.rs +++ b/src/test/invoice.rs @@ -23,6 +23,8 @@ async fn invoice() { asset_amount: Some(1), description: None, description_hash: None, + payment_hash: None, + min_final_cltv_expiry_delta: None, }; let res = reqwest::Client::new() .post(format!("http://{node1_addr}/lninvoice")) @@ -40,6 +42,8 @@ async fn invoice() { asset_amount: Some(1), description: None, description_hash: None, + payment_hash: None, + min_final_cltv_expiry_delta: None, }; let res = reqwest::Client::new() .post(format!("http://{node1_addr}/lninvoice")) @@ -57,6 +61,8 @@ async fn invoice() { asset_amount: None, description: None, description_hash: None, + payment_hash: None, + min_final_cltv_expiry_delta: None, }; let res = reqwest::Client::new() .post(format!("http://{node1_addr}/lninvoice")) @@ -70,6 +76,10 @@ async fn invoice() { // an invoice with a description should carry it as the BOLT11 d tag let description = "1 cup of coffee"; + + // check that the invoice preserves the custom min_final_cltv_expiry_delta + let requested_min_final_cltv_expiry_delta = 144; + let payload = LNInvoiceRequest { amt_msat: None, expiry_sec: 900, @@ -77,6 +87,8 @@ async fn invoice() { asset_amount: None, description: Some(description.to_string()), description_hash: None, + payment_hash: None, + min_final_cltv_expiry_delta: Some(requested_min_final_cltv_expiry_delta), }; let res = reqwest::Client::new() .post(format!("http://{node1_addr}/lninvoice")) @@ -93,6 +105,10 @@ async fn invoice() { lightning_invoice::Bolt11InvoiceDescriptionRef::Direct(d) if d.to_string() == description )); let decoded = decode_ln_invoice(node1_addr, &res.invoice).await; + assert_eq!( + decoded.min_final_cltv_expiry_delta, + u64::from(requested_min_final_cltv_expiry_delta) + 3 + ); assert_eq!(decoded.description.as_deref(), Some(description)); assert_eq!(decoded.description_hash, None); let payment = list_payments(node1_addr) @@ -112,6 +128,8 @@ async fn invoice() { asset_amount: None, description: None, description_hash: Some(description_hash.0.to_string()), + payment_hash: None, + min_final_cltv_expiry_delta: None, }; let res = reqwest::Client::new() .post(format!("http://{node1_addr}/lninvoice")) @@ -152,6 +170,8 @@ async fn invoice() { asset_amount: None, description: Some(description.to_string()), description_hash: Some(description_hash.0.to_string()), + payment_hash: None, + min_final_cltv_expiry_delta: None, }; let res = reqwest::Client::new() .post(format!("http://{node1_addr}/lninvoice")) @@ -175,6 +195,8 @@ async fn invoice() { asset_amount: None, description: None, description_hash: Some(s!("not-a-valid-description-hash")), + payment_hash: None, + min_final_cltv_expiry_delta: None, }; let res = reqwest::Client::new() .post(format!("http://{node1_addr}/lninvoice")) @@ -198,6 +220,8 @@ async fn invoice() { asset_amount: None, description: Some("a".repeat(640)), description_hash: None, + payment_hash: None, + min_final_cltv_expiry_delta: None, }; let res = reqwest::Client::new() .post(format!("http://{node1_addr}/lninvoice")) @@ -252,6 +276,8 @@ async fn zero_amount_invoice() { asset_amount: None, description: None, description_hash: None, + payment_hash: None, + min_final_cltv_expiry_delta: None, }; let res = reqwest::Client::new() .post(format!("http://{node2_addr}/lninvoice")) @@ -298,7 +324,8 @@ async fn zero_amount_invoice() { wait_for_ln_payment(node2_addr, &decoded.payment_hash, HTLCStatus::Succeeded).await; // Verify that both sender and receiver payments record the actual amount - let payment_sender = get_payment(node1_addr, &decoded.payment_hash).await; + let payment_sender = + get_payment(node1_addr, &decoded.payment_hash, PaymentType::Outbound).await; assert_eq!( payment_sender.amt_msat, Some(payment_amount), @@ -306,7 +333,12 @@ async fn zero_amount_invoice() { ); assert_eq!(payment_sender.status, HTLCStatus::Succeeded); - let payment_receiver = get_payment(node2_addr, &decoded.payment_hash).await; + let payment_receiver = get_payment( + node2_addr, + &decoded.payment_hash, + PaymentType::InboundAutoClaim, + ) + .await; assert_eq!( payment_receiver.amt_msat, Some(payment_amount), @@ -334,6 +366,8 @@ async fn zero_amount_invoice() { asset_amount: None, description: None, description_hash: None, + payment_hash: None, + min_final_cltv_expiry_delta: None, }; let invoice_without_amount = reqwest::Client::new() .post(format!("http://{node2_addr}/lninvoice")) @@ -358,6 +392,8 @@ async fn zero_amount_invoice() { asset_amount: Some(50), description: None, description_hash: None, + payment_hash: None, + min_final_cltv_expiry_delta: None, }; let invoice_with_amount = reqwest::Client::new() .post(format!("http://{node2_addr}/lninvoice")) @@ -395,7 +431,12 @@ async fn zero_amount_invoice() { HTLCStatus::Succeeded, ) .await; - let payment = get_payment(node2_addr, &decoded_with_amount.payment_hash).await; + let payment = get_payment( + node2_addr, + &decoded_with_amount.payment_hash, + PaymentType::InboundAutoClaim, + ) + .await; assert_eq!(payment.asset_id, Some(asset_id.clone())); assert_eq!(payment.asset_amount, Some(50)); @@ -488,6 +529,11 @@ async fn zero_amount_invoice() { HTLCStatus::Succeeded, ) .await; - let payment = get_payment(node2_addr, &decoded_without_amount.payment_hash).await; + let payment = get_payment( + node2_addr, + &decoded_without_amount.payment_hash, + PaymentType::InboundAutoClaim, + ) + .await; assert_eq!(payment.asset_amount, Some(100)); } diff --git a/src/test/mod.rs b/src/test/mod.rs index 8bf0654a..592e2cc4 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -19,14 +19,16 @@ use lightning::chain::transaction::TransactionData; #[cfg(all(feature = "transaction-sync", feature = "electrum"))] use lightning::chain::{Confirm, Filter}; use lightning::ln::channelmanager::DROP_FUNDING_SIGNED_ON_NODE; +use lightning::rgb_utils::{get_rgb_payment_info_path, parse_rgb_payment_info}; use lightning_invoice::Bolt11Invoice; #[cfg(all(feature = "transaction-sync", feature = "electrum"))] use lightning_transaction_sync::ElectrumSyncClient; 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::fs::File; +use std::fs::{self, File}; use std::io::{BufRead, BufReader}; use std::net::SocketAddr; use std::path::{Path, PathBuf}; @@ -42,39 +44,44 @@ use tracing_test::traced_test; #[cfg(all(feature = "transaction-sync", feature = "electrum"))] use crate::disk::FilesystemLogger; -use crate::disk::LDK_LOGS_FILE; -use crate::error::APIErrorResponse; +use crate::disk::{read_inbound_payment_info, INBOUND_PAYMENTS_FNAME, LDK_LOGS_FILE}; +use crate::error::{APIError, APIErrorResponse}; use crate::ldk::{ - DEFER_PAYMENT_CLAIMABLE_ON_NODE, FEE_RATE, FORCE_PUSH_ASSET_AMOUNT_ON_NODE, - HELD_PAYMENT_CLAIMABLE_COUNT, HOLD_PAYMENT_CLAIMABLE_ON_NODE, IGNORE_INBOUND_CHANNELS_ON_NODE, + DEFER_PAYMENT_CLAIMABLE_ON_NODE, FAIL_HODL_CLAIM_ON_NODE, FEE_RATE, + FORCE_PUSH_ASSET_AMOUNT_ON_NODE, HELD_PAYMENT_CLAIMABLE_COUNT, HODL_CLAIM_HELD, + HOLD_HODL_CLAIM_ON_NODE, HOLD_PAYMENT_CLAIMABLE_ON_NODE, IGNORE_INBOUND_CHANNELS_ON_NODE, PAYMENT_CLAIMABLE_DEFERRED, }; use crate::routes::{ AddressResponse, AssetBalanceRequest, AssetBalanceResponse, AssetCFA, AssetFilter, AssetIFA, AssetNIA, AssetUDA, Assignment, BackupRequest, BtcBalanceRequest, BtcBalanceResponse, - ChangePasswordRequest, Channel, CloseChannelRequest, ConnectPeerRequest, CreateUtxosRequest, - DecodeLNInvoiceRequest, DecodeLNInvoiceResponse, DecodeRGBInvoiceRequest, - DecodeRGBInvoiceResponse, DecodeSwapstringRequest, DecodeSwapstringResponse, - DisconnectPeerRequest, EmptyResponse, FailTransfersRequest, FailTransfersResponse, - GetAssetMediaRequest, GetAssetMediaResponse, GetChannelIdRequest, GetChannelIdResponse, - GetConsignmentRequest, GetConsignmentResponse, GetPaymentRequest, GetPaymentResponse, - GetSwapRequest, GetSwapResponse, HTLCStatus, InflateRequest, InflateResponse, InitRequest, - InitResponse, InvoiceStatus, InvoiceStatusRequest, InvoiceStatusResponse, IssueAssetCFARequest, - IssueAssetCFAResponse, IssueAssetIFARequest, IssueAssetIFAResponse, IssueAssetNIARequest, - IssueAssetNIAResponse, IssueAssetUDARequest, IssueAssetUDAResponse, KeysendRequest, - KeysendResponse, LNInvoiceRequest, LNInvoiceResponse, LdkChainSync, ListAssetsRequest, - ListAssetsResponse, ListChannelsResponse, ListPaymentsResponse, ListPeersResponse, - ListSwapsResponse, ListTransactionsRequest, ListTransactionsResponse, ListTransfersRequest, - ListTransfersResponse, ListUnspentsRequest, ListUnspentsResponse, MakerExecuteRequest, - MakerInitRequest, MakerInitResponse, NetworkInfoResponse, NodeInfoResponse, OpenChannelRequest, - OpenChannelResponse, Payment, Peer, PostAssetMediaResponse, ProvideOutOfBandAckRequest, + CancelHodlInvoiceRequest, ChangePasswordRequest, Channel, ClaimHodlInvoiceRequest, + CloseChannelRequest, ConnectPeerRequest, CreateUtxosRequest, DecodeLNInvoiceRequest, + DecodeLNInvoiceResponse, DecodeRGBInvoiceRequest, DecodeRGBInvoiceResponse, + DecodeSwapstringRequest, DecodeSwapstringResponse, DisconnectPeerRequest, EmptyResponse, + FailTransfersRequest, FailTransfersResponse, GetAssetMediaRequest, GetAssetMediaResponse, + GetChannelIdRequest, GetChannelIdResponse, GetConsignmentRequest, GetConsignmentResponse, + GetPaymentRequest, GetPaymentResponse, GetSwapRequest, GetSwapResponse, HTLCStatus, + InflateRequest, InflateResponse, InitRequest, InitResponse, InvoiceStatus, + InvoiceStatusRequest, InvoiceStatusResponse, IssueAssetCFARequest, IssueAssetCFAResponse, + IssueAssetIFARequest, IssueAssetIFAResponse, IssueAssetNIARequest, IssueAssetNIAResponse, + IssueAssetUDARequest, IssueAssetUDAResponse, KeysendRequest, KeysendResponse, LNInvoiceRequest, + LNInvoiceResponse, LdkChainSync, ListAssetsRequest, ListAssetsResponse, ListChannelsResponse, + ListPaymentsResponse, ListPeersResponse, ListSwapsResponse, ListTransactionsRequest, + ListTransactionsResponse, ListTransfersRequest, ListTransfersResponse, ListUnspentsRequest, + ListUnspentsResponse, MakerExecuteRequest, MakerInitRequest, MakerInitResponse, + NetworkInfoResponse, NodeInfoResponse, OpenChannelRequest, OpenChannelResponse, Payment, + PaymentType, Peer, PostAssetMediaResponse, ProvideOutOfBandAckRequest, ProvideOutOfBandAckResponse, ProvideOutOfBandConsignmentResponse, Recipient, RefreshRequest, RefreshResponse, RestoreRequest, RevokeTokenRequest, RgbInvoiceRequest, RgbInvoiceResponse, SendBtcRequest, SendBtcResponse, SendPaymentRequest, SendPaymentResponse, SendRgbRequest, SendRgbResponse, Swap, SwapStatus, TakerRequest, Transaction, Transfer, TransferStatus, - UnlockRequest, Unspent, WitnessData, + 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, LDK_DIR, PROXY_ENDPOINT_LOCAL}; use super::*; @@ -212,6 +219,21 @@ async fn wait_for_deferred_payment() { } } +fn hold_hodl_claim(payee_pubkey: &str) -> NodeOverrideGuard { + HODL_CLAIM_HELD.store(false, Ordering::SeqCst); + NodeOverrideGuard::set(&HOLD_HODL_CLAIM_ON_NODE, payee_pubkey) +} + +async fn wait_for_held_hodl_claim() { + let t_0 = OffsetDateTime::now_utc(); + while !HODL_CLAIM_HELD.load(Ordering::SeqCst) { + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 40.0 { + panic!("HODL claim was not held"); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } +} + fn bitcoin_cli() -> [String; 7] { [ s!("exec"), @@ -469,6 +491,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 { @@ -504,6 +538,29 @@ async fn check_payment_status( None } +async fn claim_hodl_invoice( + node_address: SocketAddr, + payment_hash: String, + payment_preimage: String, +) -> EmptyResponse { + 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 close_channel(node_address: SocketAddr, channel_id: &str, peer_pubkey: &str, force: bool) { println!( "{}closing channel {channel_id} from node {node_address}", @@ -1103,10 +1160,16 @@ 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}"); + +async fn get_payment( + node_address: SocketAddr, + payment_hash: &str, + payment_type: PaymentType, +) -> Payment { + println!("getting {payment_type:?} payment for node {node_address}"); let payload = GetPaymentRequest { payment_hash: payment_hash.to_string(), + payment_type, }; let res = reqwest::Client::new() .post(format!("http://{node_address}/getpayment")) @@ -1231,9 +1294,53 @@ async fn ln_invoice( asset_amount: Option, expiry_sec: u32, ) -> LNInvoiceResponse { - println!( - "generating invoice for {asset_amount:?} of asset {asset_id:?} for node {node_address}" - ); + ln_invoice_with_optional_payment_hash( + node_address, + amt_msat, + asset_id, + asset_amount, + expiry_sec, + None, + ) + .await +} + +async fn ln_invoice_hodl( + node_address: SocketAddr, + amt_msat: Option, + asset_id: Option<&str>, + asset_amount: Option, + expiry_sec: u32, + payment_hash: String, +) -> LNInvoiceResponse { + ln_invoice_with_optional_payment_hash( + node_address, + amt_msat, + asset_id, + asset_amount, + expiry_sec, + Some(payment_hash), + ) + .await +} + +async fn ln_invoice_with_optional_payment_hash( + node_address: SocketAddr, + amt_msat: Option, + asset_id: Option<&str>, + asset_amount: Option, + expiry_sec: u32, + payment_hash: Option, +) -> LNInvoiceResponse { + if let Some(payment_hash) = payment_hash.as_ref() { + println!( + "generating HODL invoice for {asset_amount:?} of asset {asset_id:?} for node {node_address}, payment hash: {payment_hash:?}" + ); + } else { + println!( + "generating 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, @@ -1241,6 +1348,8 @@ async fn ln_invoice( asset_amount, description: None, description_hash: None, + payment_hash, + min_final_cltv_expiry_delta: None, }; let res = reqwest::Client::new() .post(format!("http://{node_address}/lninvoice")) @@ -1707,6 +1816,14 @@ async fn provide_out_of_band_consignment( .unwrap() } +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_raw(node_address: SocketAddr) -> Result { println!("refreshing transfers for node {node_address}"); let payload = RefreshRequest { @@ -2186,6 +2303,33 @@ async fn wait_for_ln_payment( } } +async fn wait_for_inbound_payment_status( + node_test_dir: &str, + payment_hash: &str, + expected_status: HTLCStatus, +) -> Result<(), APIError> { + let hash = validate_and_parse_payment_hash(payment_hash)?; + let t_0 = OffsetDateTime::now_utc(); + loop { + 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); + if matches!( + storage.payments.get(&hash).map(|p| p.status), + Some(status) if status == expected_status + ) { + return Ok(()); + } + if (OffsetDateTime::now_utc() - t_0).as_seconds_f32() > 20.0 { + return Err(APIError::Unexpected(format!( + "inbound entry for {payment_hash} did not reach state {expected_status:?}" + ))); + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } +} + async fn wait_for_swap_status( node_address: SocketAddr, payment_hash: &str, @@ -2376,6 +2520,7 @@ mod drop_funding_signed; mod electrum_opret_confirm; 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 b11bd29d..1287ca2f 100644 --- a/src/test/payment.rs +++ b/src/test/payment.rs @@ -60,12 +60,17 @@ async fn success() { let status = invoice_status(node2_addr, &invoice).await; assert!(matches!(status, InvoiceStatus::Succeeded)); - let payment = get_payment(node1_addr, &decoded.payment_hash).await; + let payment = get_payment(node1_addr, &decoded.payment_hash, PaymentType::Outbound).await; assert_eq!(payment.asset_id, Some(asset_id.clone())); assert_eq!(payment.asset_amount, asset_amount); assert_eq!(payment.status, HTLCStatus::Succeeded); check_preimage_matches_hash(&payment, &decoded.payment_hash); - let payment = get_payment(node2_addr, &decoded.payment_hash).await; + let payment = get_payment( + node2_addr, + &decoded.payment_hash, + PaymentType::InboundAutoClaim, + ) + .await; assert_eq!(payment.asset_id, Some(asset_id.clone())); assert_eq!(payment.asset_amount, asset_amount); assert_eq!(payment.status, HTLCStatus::Succeeded); @@ -96,12 +101,17 @@ async fn success() { .await; let decoded = decode_ln_invoice(node1_addr, &invoice).await; - let payment = get_payment(node1_addr, &decoded.payment_hash).await; + let payment = get_payment( + node1_addr, + &decoded.payment_hash, + PaymentType::InboundAutoClaim, + ) + .await; assert_eq!(payment.asset_id, Some(asset_id.clone())); assert_eq!(payment.asset_amount, asset_amount); assert_eq!(payment.status, HTLCStatus::Succeeded); check_preimage_matches_hash(&payment, &decoded.payment_hash); - let payment = get_payment(node2_addr, &decoded.payment_hash).await; + let payment = get_payment(node2_addr, &decoded.payment_hash, PaymentType::Outbound).await; assert_eq!(payment.asset_id, Some(asset_id.clone())); assert_eq!(payment.asset_amount, asset_amount); assert_eq!(payment.status, HTLCStatus::Succeeded); @@ -112,12 +122,17 @@ async fn success() { let _ = send_payment(node1_addr, invoice.clone()).await; let decoded = decode_ln_invoice(node1_addr, &invoice).await; - let payment = get_payment(node1_addr, &decoded.payment_hash).await; + let payment = get_payment(node1_addr, &decoded.payment_hash, PaymentType::Outbound).await; assert_eq!(payment.asset_id, Some(asset_id.clone())); assert_eq!(payment.asset_amount, asset_amount); assert_eq!(payment.status, HTLCStatus::Succeeded); check_preimage_matches_hash(&payment, &decoded.payment_hash); - let payment = get_payment(node2_addr, &decoded.payment_hash).await; + let payment = get_payment( + node2_addr, + &decoded.payment_hash, + PaymentType::InboundAutoClaim, + ) + .await; assert_eq!(payment.asset_id, Some(asset_id.clone())); assert_eq!(payment.asset_amount, asset_amount); assert_eq!(payment.status, HTLCStatus::Succeeded); @@ -128,12 +143,17 @@ async fn success() { let _ = send_payment(node2_addr, invoice.clone()).await; let decoded = decode_ln_invoice(node1_addr, &invoice).await; - let payment = get_payment(node1_addr, &decoded.payment_hash).await; + let payment = get_payment( + node1_addr, + &decoded.payment_hash, + PaymentType::InboundAutoClaim, + ) + .await; assert_eq!(payment.asset_id, Some(asset_id.clone())); assert_eq!(payment.asset_amount, asset_amount); assert_eq!(payment.status, HTLCStatus::Succeeded); check_preimage_matches_hash(&payment, &decoded.payment_hash); - let payment = get_payment(node2_addr, &decoded.payment_hash).await; + let payment = get_payment(node2_addr, &decoded.payment_hash, PaymentType::Outbound).await; assert_eq!(payment.asset_id, Some(asset_id.clone())); assert_eq!(payment.asset_amount, asset_amount); assert_eq!(payment.status, HTLCStatus::Succeeded); @@ -314,7 +334,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(), @@ -334,7 +354,12 @@ async fn same_invoice_twice_and_expired_inbound_payments() { tokio::time::sleep(std::time::Duration::from_secs(SHORT_EXPIRY_SEC as u64 + 1)).await; // getting a payment should trigger expiration-based status transition - let payment = get_payment(node2_addr, &decoded1.payment_hash).await; + let payment = get_payment( + node2_addr, + &decoded1.payment_hash, + PaymentType::InboundAutoClaim, + ) + .await; assert_eq!( payment.status, HTLCStatus::Failed, @@ -366,7 +391,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 683a034d..a6af4670 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,5 @@ use amplify::s; +use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::Hash; use bitcoin::io; use bitcoin::secp256k1::PublicKey; @@ -12,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_invoice::{Bolt11Invoice, Bolt11InvoiceDescriptionRef}; @@ -478,3 +480,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) +}