Skip to content

Commit

Permalink
Merge pull request #3313 from valentinewallace/2024-09-fix-offer-doub…
Browse files Browse the repository at this point in the history
…le-pay

Don't pay a duplicate BOLT 12 invoice if `ChannelManager` is stale
  • Loading branch information
TheBlueMatt authored Sep 17, 2024
2 parents 429cbe1 + fbb3ab2 commit 866cedf
Show file tree
Hide file tree
Showing 7 changed files with 159 additions and 36 deletions.
34 changes: 4 additions & 30 deletions lightning/src/ln/channelmanager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ use crate::ln::onion_utils::{HTLCFailReason, INVALID_ONION_BLINDING};
use crate::ln::msgs::{ChannelMessageHandler, DecodeError, LightningError};
#[cfg(test)]
use crate::ln::outbound_payment;
use crate::ln::outbound_payment::{OutboundPayments, PaymentAttempts, PendingOutboundPayment, RetryableInvoiceRequest, SendAlongPathArgs, StaleExpiration};
use crate::ln::outbound_payment::{OutboundPayments, PendingOutboundPayment, RetryableInvoiceRequest, SendAlongPathArgs, StaleExpiration};
use crate::ln::wire::Encode;
use crate::offers::invoice::{Bolt12Invoice, DEFAULT_RELATIVE_EXPIRY, DerivedSigningPubkey, ExplicitSigningPubkey, InvoiceBuilder, UnsignedBolt12Invoice};
use crate::offers::invoice_error::InvoiceError;
Expand Down Expand Up @@ -12569,37 +12569,11 @@ where
return Err(DecodeError::InvalidValue);
}

let path_amt = path.final_value_msat();
let mut session_priv_bytes = [0; 32];
session_priv_bytes[..].copy_from_slice(&session_priv[..]);
match pending_outbounds.pending_outbound_payments.lock().unwrap().entry(payment_id) {
hash_map::Entry::Occupied(mut entry) => {
let newly_added = entry.get_mut().insert(session_priv_bytes, &path);
log_info!(logger, "{} a pending payment path for {} msat for session priv {} on an existing pending payment with payment hash {}",
if newly_added { "Added" } else { "Had" }, path_amt, log_bytes!(session_priv_bytes), htlc.payment_hash);
},
hash_map::Entry::Vacant(entry) => {
let path_fee = path.fee_msat();
entry.insert(PendingOutboundPayment::Retryable {
retry_strategy: None,
attempts: PaymentAttempts::new(),
payment_params: None,
session_privs: hash_set_from_iter([session_priv_bytes]),
payment_hash: htlc.payment_hash,
payment_secret: None, // only used for retries, and we'll never retry on startup
payment_metadata: None, // only used for retries, and we'll never retry on startup
keysend_preimage: None, // only used for retries, and we'll never retry on startup
custom_tlvs: Vec::new(), // only used for retries, and we'll never retry on startup
pending_amt_msat: path_amt,
pending_fee_msat: Some(path_fee),
total_msat: path_amt,
starting_block_height: best_block_height,
remaining_max_total_routing_fee_msat: None, // only used for retries, and we'll never retry on startup
});
log_info!(logger, "Added a pending payment for {} msat with payment hash {} for path with session priv {}",
path_amt, &htlc.payment_hash, log_bytes!(session_priv_bytes));
}
}
pending_outbounds.insert_from_monitor_on_startup(
payment_id, htlc.payment_hash, session_priv_bytes, &path, best_block_height, logger
);
}
}
for (htlc_source, (htlc, preimage_opt)) in monitor.get_all_current_outbound_htlcs() {
Expand Down
4 changes: 2 additions & 2 deletions lightning/src/ln/functional_test_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1144,8 +1144,8 @@ macro_rules! reload_node {
($node: expr, $new_config: expr, $chanman_encoded: expr, $monitors_encoded: expr, $persister: ident, $new_chain_monitor: ident, $new_channelmanager: ident) => {
let chanman_encoded = $chanman_encoded;

$persister = test_utils::TestPersister::new();
$new_chain_monitor = test_utils::TestChainMonitor::new(Some($node.chain_source), $node.tx_broadcaster.clone(), $node.logger, $node.fee_estimator, &$persister, &$node.keys_manager);
$persister = $crate::util::test_utils::TestPersister::new();
$new_chain_monitor = $crate::util::test_utils::TestChainMonitor::new(Some($node.chain_source), $node.tx_broadcaster.clone(), $node.logger, $node.fee_estimator, &$persister, &$node.keys_manager);
$node.chain_monitor = &$new_chain_monitor;

$new_channelmanager = _reload_node(&$node, $new_config, &chanman_encoded, $monitors_encoded);
Expand Down
1 change: 0 additions & 1 deletion lightning/src/ln/monitor_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ use crate::ln::msgs::ChannelMessageHandler;
use crate::crypto::utils::sign;
use crate::util::ser::Writeable;
use crate::util::scid_utils::block_from_scid;
use crate::util::test_utils;

use bitcoin::{Amount, PublicKey, ScriptBuf, Transaction, TxIn, TxOut, Witness};
use bitcoin::locktime::absolute::LockTime;
Expand Down
92 changes: 91 additions & 1 deletion lightning/src/ln/offers_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ use crate::blinded_path::IntroductionNode;
use crate::blinded_path::message::BlindedMessagePath;
use crate::blinded_path::payment::{Bolt12OfferContext, Bolt12RefundContext, PaymentContext};
use crate::blinded_path::message::{MessageContext, OffersContext};
use crate::events::{Event, MessageSendEventsProvider, PaymentFailureReason, PaymentPurpose};
use crate::events::{ClosureReason, Event, MessageSendEventsProvider, PaymentFailureReason, PaymentPurpose};
use crate::ln::channelmanager::{Bolt12PaymentError, MAX_SHORT_LIVED_RELATIVE_EXPIRY, PaymentId, RecentPaymentDetails, Retry, self};
use crate::ln::features::Bolt12InvoiceFeatures;
use crate::ln::functional_test_utils::*;
Expand All @@ -64,6 +64,7 @@ use crate::onion_message::offers::OffersMessage;
use crate::onion_message::packet::ParsedOnionMessageContents;
use crate::routing::gossip::{NodeAlias, NodeId};
use crate::sign::{NodeSigner, Recipient};
use crate::util::ser::Writeable;

use crate::prelude::*;

Expand Down Expand Up @@ -2253,3 +2254,92 @@ fn fails_paying_invoice_with_unknown_required_features() {
_ => panic!("Expected Event::PaymentFailed with reason"),
}
}

#[test]
fn no_double_pay_with_stale_channelmanager() {
// This tests the following bug:
// - An outbound payment is AwaitingInvoice
// - We receive an invoice and lock the HTLCs into the relevant ChannelMonitors
// - The monitors are successfully persisted, but the ChannelManager fails to persist, so the
// payment remains AwaitingInvoice
// - We restart, causing the channels to close due to a stale ChannelManager
// - We receive a duplicate invoice, and attempt to pay it again due to the payment still being
// AwaitingInvoice in the stale ChannelManager
// After the fix for this, we will notice that the payment is already locked into the monitors on
// startup and transition the incorrectly-AwaitingInvoice payment to Retryable, which prevents
// double-paying on duplicate invoice receipt.
let chanmon_cfgs = create_chanmon_cfgs(2);
let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
let persister;
let chain_monitor;
let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
let alice_deserialized;
let mut nodes = create_network(2, &node_cfgs, &node_chanmgrs);
let chan_id_0 = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000).2;
let chan_id_1 = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000).2;

let alice_id = nodes[0].node.get_our_node_id();
let bob_id = nodes[1].node.get_our_node_id();

let amt_msat = nodes[0].node.list_usable_channels()[0].next_outbound_htlc_limit_msat + 1; // Force MPP
let offer = nodes[1].node
.create_offer_builder(None).unwrap()
.clear_paths()
.amount_msats(amt_msat)
.build().unwrap();
assert_eq!(offer.signing_pubkey(), Some(bob_id));
assert!(offer.paths().is_empty());

let payment_id = PaymentId([1; 32]);
nodes[0].node.pay_for_offer(&offer, None, None, None, payment_id, Retry::Attempts(0), None).unwrap();
expect_recent_payment!(nodes[0], RecentPaymentDetails::AwaitingInvoice, payment_id);

let invreq_om = nodes[0].onion_messenger.next_onion_message_for_peer(bob_id).unwrap();
nodes[1].onion_messenger.handle_onion_message(alice_id, &invreq_om);

// Save the manager while the payment is in state AwaitingInvoice so we can reload it later.
let alice_chan_manager_serialized = nodes[0].node.encode();

let invoice_om = nodes[1].onion_messenger.next_onion_message_for_peer(alice_id).unwrap();
nodes[0].onion_messenger.handle_onion_message(bob_id, &invoice_om);
let payment_hash = extract_invoice(&nodes[0], &invoice_om).0.payment_hash();

let expected_route: &[&[&Node]] = &[&[&nodes[1]], &[&nodes[1]]];
let mut events = nodes[0].node.get_and_clear_pending_msg_events();
assert_eq!(events.len(), 2);
check_added_monitors!(nodes[0], 2);

let ev = remove_first_msg_event_to_node(&bob_id, &mut events);
let args = PassAlongPathArgs::new(&nodes[0], expected_route[0], amt_msat, payment_hash, ev)
.without_clearing_recipient_events();
do_pass_along_path(args);

let ev = remove_first_msg_event_to_node(&bob_id, &mut events);
let args = PassAlongPathArgs::new(&nodes[0], expected_route[0], amt_msat, payment_hash, ev)
.without_clearing_recipient_events();
do_pass_along_path(args);

expect_recent_payment!(nodes[0], RecentPaymentDetails::Pending, payment_id);
match get_event!(nodes[1], Event::PaymentClaimable) {
Event::PaymentClaimable { .. } => {},
_ => panic!("No Event::PaymentClaimable"),
}

// Reload with the stale manager and check that receiving the invoice again won't result in a
// duplicate payment attempt.
let monitor_0 = get_monitor!(nodes[0], chan_id_0).encode();
let monitor_1 = get_monitor!(nodes[0], chan_id_1).encode();
reload_node!(nodes[0], &alice_chan_manager_serialized, &[&monitor_0, &monitor_1], persister, chain_monitor, alice_deserialized);
// The stale manager results in closing the channels.
check_closed_event!(nodes[0], 2, ClosureReason::OutdatedChannelManager, [bob_id, bob_id], 10_000_000);
check_added_monitors!(nodes[0], 2);

// Alice receives a duplicate invoice, but the payment should be transitioned to Retryable by now.
nodes[0].onion_messenger.handle_onion_message(bob_id, &invoice_om);
// Previously, Alice would've attempted to pay the invoice a 2nd time. In this test case, this 2nd
// attempt would have resulted in a PaymentFailed event here, since the only channels between
// Alice and Bob is closed. Since no 2nd attempt should be made, check that no events are
// generated in response to the duplicate invoice.
assert!(nodes[0].node.get_and_clear_pending_events().is_empty());
}

62 changes: 62 additions & 0 deletions lightning/src/ln/outbound_payment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2121,6 +2121,68 @@ impl OutboundPayments {
self.awaiting_invoice.store(false, Ordering::Release);
invoice_requests
}

pub(super) fn insert_from_monitor_on_startup<L: Logger>(
&self, payment_id: PaymentId, payment_hash: PaymentHash, session_priv_bytes: [u8; 32],
path: &Path, best_block_height: u32, logger: L
) {
let path_amt = path.final_value_msat();
let path_fee = path.fee_msat();

macro_rules! new_retryable {
() => {
PendingOutboundPayment::Retryable {
retry_strategy: None,
attempts: PaymentAttempts::new(),
payment_params: None,
session_privs: hash_set_from_iter([session_priv_bytes]),
payment_hash,
payment_secret: None, // only used for retries, and we'll never retry on startup
payment_metadata: None, // only used for retries, and we'll never retry on startup
keysend_preimage: None, // only used for retries, and we'll never retry on startup
custom_tlvs: Vec::new(), // only used for retries, and we'll never retry on startup
pending_amt_msat: path_amt,
pending_fee_msat: Some(path_fee),
total_msat: path_amt,
starting_block_height: best_block_height,
remaining_max_total_routing_fee_msat: None, // only used for retries, and we'll never retry on startup
}
}
}

match self.pending_outbound_payments.lock().unwrap().entry(payment_id) {
hash_map::Entry::Occupied(mut entry) => {
let newly_added = match entry.get() {
PendingOutboundPayment::AwaitingInvoice { .. } |
PendingOutboundPayment::InvoiceReceived { .. } |
PendingOutboundPayment::StaticInvoiceReceived { .. } =>
{
// If we've reached this point, it means we initiated a payment to a BOLT 12 invoice and
// locked the htlc(s) into the `ChannelMonitor`(s), but failed to persist the
// `ChannelManager` after transitioning from this state to `Retryable` prior to shutdown.
// Therefore, we need to move this payment to `Retryable` now to avoid double-paying if
// the recipient sends a duplicate invoice or release_held_htlc onion message.
*entry.get_mut() = new_retryable!();
true
},
PendingOutboundPayment::Legacy { .. } |
PendingOutboundPayment::Retryable { .. } |
PendingOutboundPayment::Fulfilled { .. } |
PendingOutboundPayment::Abandoned { .. } =>
{
entry.get_mut().insert(session_priv_bytes, &path)
}
};
log_info!(logger, "{} a pending payment path for {} msat for session priv {} on an existing pending payment with payment hash {}",
if newly_added { "Added" } else { "Had" }, path_amt, log_bytes!(session_priv_bytes), payment_hash);
},
hash_map::Entry::Vacant(entry) => {
entry.insert(new_retryable!());
log_info!(logger, "Added a pending payment for {} msat with payment hash {} for path with session priv {}",
path_amt, payment_hash, log_bytes!(session_priv_bytes));
}
}
}
}

/// Returns whether a payment with the given [`PaymentHash`] and [`PaymentId`] is, in fact, a
Expand Down
1 change: 0 additions & 1 deletion lightning/src/ln/priv_short_conf_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ use crate::ln::msgs::{ChannelMessageHandler, RoutingMessageHandler, ChannelUpdat
use crate::ln::wire::Encode;
use crate::util::config::{UserConfig, MaxDustHTLCExposure};
use crate::util::ser::Writeable;
use crate::util::test_utils;

use crate::prelude::*;

Expand Down
1 change: 0 additions & 1 deletion lightning/src/ln/reorg_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ use crate::events::{Event, MessageSendEventsProvider, ClosureReason, HTLCDestina
use crate::ln::msgs::{ChannelMessageHandler, Init};
use crate::ln::types::ChannelId;
use crate::sign::OutputSpender;
use crate::util::test_utils;
use crate::util::ser::Writeable;
use crate::util::string::UntrustedString;

Expand Down

0 comments on commit 866cedf

Please sign in to comment.