mirror of
https://github.com/lightningdevkit/rust-lightning.git
synced 2025-02-24 23:08:36 +01:00
Merge pull request #3408 from valentinewallace/2024-11-async-receive-offer-utils
Add static invoice creation utils to `ChannelManager`
This commit is contained in:
commit
aa2c6fed24
15 changed files with 829 additions and 195 deletions
|
@ -133,7 +133,7 @@ impl Router for FuzzRouter {
|
|||
|
||||
fn create_blinded_payment_paths<T: secp256k1::Signing + secp256k1::Verification>(
|
||||
&self, _recipient: PublicKey, _first_hops: Vec<ChannelDetails>, _tlvs: ReceiveTlvs,
|
||||
_amount_msats: u64, _secp_ctx: &Secp256k1<T>,
|
||||
_amount_msats: Option<u64>, _secp_ctx: &Secp256k1<T>,
|
||||
) -> Result<Vec<BlindedPaymentPath>, ()> {
|
||||
unreachable!()
|
||||
}
|
||||
|
|
|
@ -160,7 +160,7 @@ impl Router for FuzzRouter {
|
|||
|
||||
fn create_blinded_payment_paths<T: secp256k1::Signing + secp256k1::Verification>(
|
||||
&self, _recipient: PublicKey, _first_hops: Vec<ChannelDetails>, _tlvs: ReceiveTlvs,
|
||||
_amount_msats: u64, _secp_ctx: &Secp256k1<T>,
|
||||
_amount_msats: Option<u64>, _secp_ctx: &Secp256k1<T>,
|
||||
) -> Result<Vec<BlindedPaymentPath>, ()> {
|
||||
unreachable!()
|
||||
}
|
||||
|
|
|
@ -402,6 +402,24 @@ pub enum AsyncPaymentsContext {
|
|||
/// containing the expected [`PaymentId`].
|
||||
hmac: Hmac<Sha256>,
|
||||
},
|
||||
/// Context contained within the [`BlindedMessagePath`]s we put in static invoices, provided back
|
||||
/// to us in corresponding [`HeldHtlcAvailable`] messages.
|
||||
///
|
||||
/// [`HeldHtlcAvailable`]: crate::onion_message::async_payments::HeldHtlcAvailable
|
||||
InboundPayment {
|
||||
/// A nonce used for authenticating that a [`HeldHtlcAvailable`] message is valid for a
|
||||
/// preceding static invoice.
|
||||
///
|
||||
/// [`HeldHtlcAvailable`]: crate::onion_message::async_payments::HeldHtlcAvailable
|
||||
nonce: Nonce,
|
||||
/// Authentication code for the [`HeldHtlcAvailable`] message.
|
||||
///
|
||||
/// Prevents nodes from creating their own blinded path to us, sending a [`HeldHtlcAvailable`]
|
||||
/// message and trivially getting notified whenever we come online.
|
||||
///
|
||||
/// [`HeldHtlcAvailable`]: crate::onion_message::async_payments::HeldHtlcAvailable
|
||||
hmac: Hmac<Sha256>,
|
||||
},
|
||||
}
|
||||
|
||||
impl_writeable_tlv_based_enum!(MessageContext,
|
||||
|
@ -433,6 +451,10 @@ impl_writeable_tlv_based_enum!(AsyncPaymentsContext,
|
|||
(2, nonce, required),
|
||||
(4, hmac, required),
|
||||
},
|
||||
(1, InboundPayment) => {
|
||||
(0, nonce, required),
|
||||
(2, hmac, required),
|
||||
},
|
||||
);
|
||||
|
||||
/// Contains a simple nonce for use in a blinded path's context.
|
||||
|
|
|
@ -349,6 +349,11 @@ pub enum PaymentContext {
|
|||
/// [`Offer`]: crate::offers::offer::Offer
|
||||
Bolt12Offer(Bolt12OfferContext),
|
||||
|
||||
/// The payment was made for a static invoice requested from a BOLT 12 [`Offer`].
|
||||
///
|
||||
/// [`Offer`]: crate::offers::offer::Offer
|
||||
AsyncBolt12Offer(AsyncBolt12OfferContext),
|
||||
|
||||
/// The payment was made for an invoice sent for a BOLT 12 [`Refund`].
|
||||
///
|
||||
/// [`Refund`]: crate::offers::refund::Refund
|
||||
|
@ -378,6 +383,18 @@ pub struct Bolt12OfferContext {
|
|||
pub invoice_request: InvoiceRequestFields,
|
||||
}
|
||||
|
||||
/// The context of a payment made for a static invoice requested from a BOLT 12 [`Offer`].
|
||||
///
|
||||
/// [`Offer`]: crate::offers::offer::Offer
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct AsyncBolt12OfferContext {
|
||||
/// The [`Nonce`] used to verify that an inbound [`InvoiceRequest`] corresponds to this static
|
||||
/// invoice's offer.
|
||||
///
|
||||
/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
|
||||
pub offer_nonce: Nonce,
|
||||
}
|
||||
|
||||
/// The context of a payment made for an invoice sent for a BOLT 12 [`Refund`].
|
||||
///
|
||||
/// [`Refund`]: crate::offers::refund::Refund
|
||||
|
@ -627,6 +644,7 @@ impl_writeable_tlv_based_enum_legacy!(PaymentContext,
|
|||
// 0 for Unknown removed in version 0.1.
|
||||
(1, Bolt12Offer),
|
||||
(2, Bolt12Refund),
|
||||
(3, AsyncBolt12Offer),
|
||||
);
|
||||
|
||||
impl<'a> Writeable for PaymentContextRef<'a> {
|
||||
|
@ -651,6 +669,10 @@ impl_writeable_tlv_based!(Bolt12OfferContext, {
|
|||
(2, invoice_request, required),
|
||||
});
|
||||
|
||||
impl_writeable_tlv_based!(AsyncBolt12OfferContext, {
|
||||
(0, offer_nonce, required),
|
||||
});
|
||||
|
||||
impl_writeable_tlv_based!(Bolt12RefundContext, {});
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
@ -181,27 +181,32 @@ impl PaymentPurpose {
|
|||
pub(crate) fn from_parts(
|
||||
payment_preimage: Option<PaymentPreimage>, payment_secret: PaymentSecret,
|
||||
payment_context: Option<PaymentContext>,
|
||||
) -> Self {
|
||||
) -> Result<Self, ()> {
|
||||
match payment_context {
|
||||
None => {
|
||||
PaymentPurpose::Bolt11InvoicePayment {
|
||||
Ok(PaymentPurpose::Bolt11InvoicePayment {
|
||||
payment_preimage,
|
||||
payment_secret,
|
||||
}
|
||||
})
|
||||
},
|
||||
Some(PaymentContext::Bolt12Offer(context)) => {
|
||||
PaymentPurpose::Bolt12OfferPayment {
|
||||
Ok(PaymentPurpose::Bolt12OfferPayment {
|
||||
payment_preimage,
|
||||
payment_secret,
|
||||
payment_context: context,
|
||||
}
|
||||
})
|
||||
},
|
||||
Some(PaymentContext::Bolt12Refund(context)) => {
|
||||
PaymentPurpose::Bolt12RefundPayment {
|
||||
Ok(PaymentPurpose::Bolt12RefundPayment {
|
||||
payment_preimage,
|
||||
payment_secret,
|
||||
payment_context: context,
|
||||
}
|
||||
})
|
||||
},
|
||||
Some(PaymentContext::AsyncBolt12Offer(_context)) => {
|
||||
// This code will change to return Self::Bolt12OfferPayment when we add support for async
|
||||
// receive.
|
||||
Err(())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -1865,7 +1870,8 @@ impl MaybeReadable for Event {
|
|||
(13, payment_id, option),
|
||||
});
|
||||
let purpose = match payment_secret {
|
||||
Some(secret) => PaymentPurpose::from_parts(payment_preimage, secret, payment_context),
|
||||
Some(secret) => PaymentPurpose::from_parts(payment_preimage, secret, payment_context)
|
||||
.map_err(|()| msgs::DecodeError::InvalidValue)?,
|
||||
None if payment_preimage.is_some() => PaymentPurpose::SpontaneousPayment(payment_preimage.unwrap()),
|
||||
None => return Err(msgs::DecodeError::InvalidValue),
|
||||
};
|
||||
|
|
601
lightning/src/ln/async_payments_tests.rs
Normal file
601
lightning/src/ln/async_payments_tests.rs
Normal file
|
@ -0,0 +1,601 @@
|
|||
// This file is Copyright its original authors, visible in version control
|
||||
// history.
|
||||
//
|
||||
// This file is licensed under the Apache License, Version 2.0 <LICENSE-APACHE
|
||||
// or http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
|
||||
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your option.
|
||||
// You may not use this file except in accordance with one or both of these
|
||||
// licenses.
|
||||
|
||||
use crate::blinded_path::message::{MessageContext, OffersContext};
|
||||
use crate::events::{Event, HTLCDestination, MessageSendEventsProvider, PaymentFailureReason};
|
||||
use crate::ln::blinded_payment_tests::{blinded_payment_path, get_blinded_route_parameters};
|
||||
use crate::ln::channelmanager;
|
||||
use crate::ln::channelmanager::{PaymentId, RecipientOnionFields};
|
||||
use crate::ln::functional_test_utils::*;
|
||||
use crate::ln::inbound_payment;
|
||||
use crate::ln::msgs::ChannelMessageHandler;
|
||||
use crate::ln::msgs::OnionMessageHandler;
|
||||
use crate::ln::offers_tests;
|
||||
use crate::ln::onion_utils::INVALID_ONION_BLINDING;
|
||||
use crate::ln::outbound_payment::Retry;
|
||||
use crate::offers::nonce::Nonce;
|
||||
use crate::onion_message::async_payments::{
|
||||
AsyncPaymentsMessage, AsyncPaymentsMessageHandler, ReleaseHeldHtlc,
|
||||
};
|
||||
use crate::onion_message::messenger::{Destination, MessageRouter, MessageSendInstructions};
|
||||
use crate::onion_message::offers::OffersMessage;
|
||||
use crate::onion_message::packet::ParsedOnionMessageContents;
|
||||
use crate::prelude::*;
|
||||
use crate::routing::router::{PaymentParameters, RouteParameters};
|
||||
use crate::sign::NodeSigner;
|
||||
use crate::types::features::Bolt12InvoiceFeatures;
|
||||
use crate::types::payment::{PaymentPreimage, PaymentSecret};
|
||||
use crate::util::config::UserConfig;
|
||||
use bitcoin::secp256k1::Secp256k1;
|
||||
|
||||
use core::convert::Infallible;
|
||||
use core::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn blinded_keysend() {
|
||||
let chanmon_cfgs = create_chanmon_cfgs(3);
|
||||
let node_cfgs = create_node_cfgs(3, &chanmon_cfgs);
|
||||
let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]);
|
||||
let mut nodes = create_network(3, &node_cfgs, &node_chanmgrs);
|
||||
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0);
|
||||
let chan_upd_1_2 =
|
||||
create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0).0.contents;
|
||||
|
||||
let inbound_payment_key = nodes[2].keys_manager.get_inbound_payment_key();
|
||||
let payment_secret = inbound_payment::create_for_spontaneous_payment(
|
||||
&inbound_payment_key,
|
||||
None,
|
||||
u32::MAX,
|
||||
nodes[2].node.duration_since_epoch().as_secs(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let amt_msat = 5000;
|
||||
let keysend_preimage = PaymentPreimage([42; 32]);
|
||||
let route_params = get_blinded_route_parameters(
|
||||
amt_msat,
|
||||
payment_secret,
|
||||
1,
|
||||
1_0000_0000,
|
||||
nodes.iter().skip(1).map(|n| n.node.get_our_node_id()).collect(),
|
||||
&[&chan_upd_1_2],
|
||||
&chanmon_cfgs[2].keys_manager,
|
||||
);
|
||||
|
||||
let payment_hash = nodes[0]
|
||||
.node
|
||||
.send_spontaneous_payment(
|
||||
Some(keysend_preimage),
|
||||
RecipientOnionFields::spontaneous_empty(),
|
||||
PaymentId(keysend_preimage.0),
|
||||
route_params,
|
||||
Retry::Attempts(0),
|
||||
)
|
||||
.unwrap();
|
||||
check_added_monitors(&nodes[0], 1);
|
||||
|
||||
let expected_route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]];
|
||||
let mut events = nodes[0].node.get_and_clear_pending_msg_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
let ev = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events);
|
||||
pass_along_path(
|
||||
&nodes[0],
|
||||
expected_route[0],
|
||||
amt_msat,
|
||||
payment_hash,
|
||||
Some(payment_secret),
|
||||
ev.clone(),
|
||||
true,
|
||||
Some(keysend_preimage),
|
||||
);
|
||||
claim_payment_along_route(ClaimAlongRouteArgs::new(
|
||||
&nodes[0],
|
||||
expected_route,
|
||||
keysend_preimage,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blinded_mpp_keysend() {
|
||||
let chanmon_cfgs = create_chanmon_cfgs(4);
|
||||
let node_cfgs = create_node_cfgs(4, &chanmon_cfgs);
|
||||
let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &[None, None, None, None]);
|
||||
let nodes = create_network(4, &node_cfgs, &node_chanmgrs);
|
||||
|
||||
create_announced_chan_between_nodes(&nodes, 0, 1);
|
||||
create_announced_chan_between_nodes(&nodes, 0, 2);
|
||||
let chan_1_3 = create_announced_chan_between_nodes(&nodes, 1, 3);
|
||||
let chan_2_3 = create_announced_chan_between_nodes(&nodes, 2, 3);
|
||||
|
||||
let inbound_payment_key = nodes[3].keys_manager.get_inbound_payment_key();
|
||||
let payment_secret = inbound_payment::create_for_spontaneous_payment(
|
||||
&inbound_payment_key,
|
||||
None,
|
||||
u32::MAX,
|
||||
nodes[3].node.duration_since_epoch().as_secs(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let amt_msat = 15_000_000;
|
||||
let keysend_preimage = PaymentPreimage([42; 32]);
|
||||
let route_params = {
|
||||
let pay_params = PaymentParameters::blinded(vec![
|
||||
blinded_payment_path(
|
||||
payment_secret,
|
||||
1,
|
||||
1_0000_0000,
|
||||
vec![nodes[1].node.get_our_node_id(), nodes[3].node.get_our_node_id()],
|
||||
&[&chan_1_3.0.contents],
|
||||
&chanmon_cfgs[3].keys_manager,
|
||||
),
|
||||
blinded_payment_path(
|
||||
payment_secret,
|
||||
1,
|
||||
1_0000_0000,
|
||||
vec![nodes[2].node.get_our_node_id(), nodes[3].node.get_our_node_id()],
|
||||
&[&chan_2_3.0.contents],
|
||||
&chanmon_cfgs[3].keys_manager,
|
||||
),
|
||||
])
|
||||
.with_bolt12_features(channelmanager::provided_bolt12_invoice_features(
|
||||
&UserConfig::default(),
|
||||
))
|
||||
.unwrap();
|
||||
RouteParameters::from_payment_params_and_value(pay_params, amt_msat)
|
||||
};
|
||||
|
||||
let payment_hash = nodes[0]
|
||||
.node
|
||||
.send_spontaneous_payment(
|
||||
Some(keysend_preimage),
|
||||
RecipientOnionFields::spontaneous_empty(),
|
||||
PaymentId(keysend_preimage.0),
|
||||
route_params,
|
||||
Retry::Attempts(0),
|
||||
)
|
||||
.unwrap();
|
||||
check_added_monitors!(nodes[0], 2);
|
||||
|
||||
let expected_route: &[&[&Node]] = &[&[&nodes[1], &nodes[3]], &[&nodes[2], &nodes[3]]];
|
||||
let mut events = nodes[0].node.get_and_clear_pending_msg_events();
|
||||
assert_eq!(events.len(), 2);
|
||||
|
||||
let ev = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events);
|
||||
pass_along_path(
|
||||
&nodes[0],
|
||||
expected_route[0],
|
||||
amt_msat,
|
||||
payment_hash.clone(),
|
||||
Some(payment_secret),
|
||||
ev.clone(),
|
||||
false,
|
||||
Some(keysend_preimage),
|
||||
);
|
||||
|
||||
let ev = remove_first_msg_event_to_node(&nodes[2].node.get_our_node_id(), &mut events);
|
||||
pass_along_path(
|
||||
&nodes[0],
|
||||
expected_route[1],
|
||||
amt_msat,
|
||||
payment_hash.clone(),
|
||||
Some(payment_secret),
|
||||
ev.clone(),
|
||||
true,
|
||||
Some(keysend_preimage),
|
||||
);
|
||||
claim_payment_along_route(ClaimAlongRouteArgs::new(
|
||||
&nodes[0],
|
||||
expected_route,
|
||||
keysend_preimage,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_keysend_payment_secret() {
|
||||
let chanmon_cfgs = create_chanmon_cfgs(3);
|
||||
let node_cfgs = create_node_cfgs(3, &chanmon_cfgs);
|
||||
let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]);
|
||||
let mut nodes = create_network(3, &node_cfgs, &node_chanmgrs);
|
||||
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0);
|
||||
let chan_upd_1_2 =
|
||||
create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0).0.contents;
|
||||
|
||||
let invalid_payment_secret = PaymentSecret([42; 32]);
|
||||
let amt_msat = 5000;
|
||||
let keysend_preimage = PaymentPreimage([42; 32]);
|
||||
let route_params = get_blinded_route_parameters(
|
||||
amt_msat,
|
||||
invalid_payment_secret,
|
||||
1,
|
||||
1_0000_0000,
|
||||
nodes.iter().skip(1).map(|n| n.node.get_our_node_id()).collect(),
|
||||
&[&chan_upd_1_2],
|
||||
&chanmon_cfgs[2].keys_manager,
|
||||
);
|
||||
|
||||
let payment_hash = nodes[0]
|
||||
.node
|
||||
.send_spontaneous_payment(
|
||||
Some(keysend_preimage),
|
||||
RecipientOnionFields::spontaneous_empty(),
|
||||
PaymentId(keysend_preimage.0),
|
||||
route_params,
|
||||
Retry::Attempts(0),
|
||||
)
|
||||
.unwrap();
|
||||
check_added_monitors(&nodes[0], 1);
|
||||
|
||||
let expected_route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]];
|
||||
let mut events = nodes[0].node.get_and_clear_pending_msg_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
let ev = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events);
|
||||
let args =
|
||||
PassAlongPathArgs::new(&nodes[0], &expected_route[0], amt_msat, payment_hash, ev.clone())
|
||||
.with_payment_secret(invalid_payment_secret)
|
||||
.with_payment_preimage(keysend_preimage)
|
||||
.expect_failure(HTLCDestination::FailedPayment { payment_hash });
|
||||
do_pass_along_path(args);
|
||||
|
||||
let updates_2_1 = get_htlc_update_msgs!(nodes[2], nodes[1].node.get_our_node_id());
|
||||
assert_eq!(updates_2_1.update_fail_malformed_htlcs.len(), 1);
|
||||
let update_malformed = &updates_2_1.update_fail_malformed_htlcs[0];
|
||||
assert_eq!(update_malformed.sha256_of_onion, [0; 32]);
|
||||
assert_eq!(update_malformed.failure_code, INVALID_ONION_BLINDING);
|
||||
nodes[1]
|
||||
.node
|
||||
.handle_update_fail_malformed_htlc(nodes[2].node.get_our_node_id(), update_malformed);
|
||||
do_commitment_signed_dance(&nodes[1], &nodes[2], &updates_2_1.commitment_signed, true, false);
|
||||
|
||||
let updates_1_0 = get_htlc_update_msgs!(nodes[1], nodes[0].node.get_our_node_id());
|
||||
assert_eq!(updates_1_0.update_fail_htlcs.len(), 1);
|
||||
nodes[0].node.handle_update_fail_htlc(
|
||||
nodes[1].node.get_our_node_id(),
|
||||
&updates_1_0.update_fail_htlcs[0],
|
||||
);
|
||||
do_commitment_signed_dance(&nodes[0], &nodes[1], &updates_1_0.commitment_signed, false, false);
|
||||
expect_payment_failed_conditions(
|
||||
&nodes[0],
|
||||
payment_hash,
|
||||
false,
|
||||
PaymentFailedConditions::new().expected_htlc_error_data(INVALID_ONION_BLINDING, &[0; 32]),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_invoice_unknown_required_features() {
|
||||
// Test that we will fail to pay a static invoice with unsupported required features.
|
||||
let secp_ctx = Secp256k1::new();
|
||||
let chanmon_cfgs = create_chanmon_cfgs(3);
|
||||
let node_cfgs = create_node_cfgs(3, &chanmon_cfgs);
|
||||
let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]);
|
||||
let nodes = create_network(3, &node_cfgs, &node_chanmgrs);
|
||||
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0);
|
||||
create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0);
|
||||
|
||||
let blinded_paths_to_always_online_node = nodes[1]
|
||||
.message_router
|
||||
.create_blinded_paths(
|
||||
nodes[1].node.get_our_node_id(),
|
||||
MessageContext::Offers(OffersContext::InvoiceRequest { nonce: Nonce([42; 16]) }),
|
||||
Vec::new(),
|
||||
&secp_ctx,
|
||||
)
|
||||
.unwrap();
|
||||
let (offer_builder, nonce) = nodes[2]
|
||||
.node
|
||||
.create_async_receive_offer_builder(blinded_paths_to_always_online_node)
|
||||
.unwrap();
|
||||
let offer = offer_builder.build().unwrap();
|
||||
let static_invoice_unknown_req_features = nodes[2]
|
||||
.node
|
||||
.create_static_invoice_builder(&offer, nonce, None)
|
||||
.unwrap()
|
||||
.features_unchecked(Bolt12InvoiceFeatures::unknown())
|
||||
.build_and_sign(&secp_ctx)
|
||||
.unwrap();
|
||||
|
||||
let amt_msat = 5000;
|
||||
let payment_id = PaymentId([1; 32]);
|
||||
nodes[0]
|
||||
.node
|
||||
.pay_for_offer(&offer, None, Some(amt_msat), None, payment_id, Retry::Attempts(0), None)
|
||||
.unwrap();
|
||||
|
||||
// Don't forward the invreq since we don't support retrieving the static invoice from the
|
||||
// recipient's LSP yet, instead manually construct the response.
|
||||
let invreq_om = nodes[0]
|
||||
.onion_messenger
|
||||
.next_onion_message_for_peer(nodes[1].node.get_our_node_id())
|
||||
.unwrap();
|
||||
let invreq_reply_path = offers_tests::extract_invoice_request(&nodes[1], &invreq_om).1;
|
||||
nodes[1]
|
||||
.onion_messenger
|
||||
.send_onion_message(
|
||||
ParsedOnionMessageContents::<Infallible>::Offers(OffersMessage::StaticInvoice(
|
||||
static_invoice_unknown_req_features,
|
||||
)),
|
||||
MessageSendInstructions::WithoutReplyPath {
|
||||
destination: Destination::BlindedPath(invreq_reply_path),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let static_invoice_om = nodes[1]
|
||||
.onion_messenger
|
||||
.next_onion_message_for_peer(nodes[0].node.get_our_node_id())
|
||||
.unwrap();
|
||||
nodes[0]
|
||||
.onion_messenger
|
||||
.handle_onion_message(nodes[1].node.get_our_node_id(), &static_invoice_om);
|
||||
let events = nodes[0].node.get_and_clear_pending_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
match events[0] {
|
||||
Event::PaymentFailed { payment_hash, payment_id: ev_payment_id, reason } => {
|
||||
assert_eq!(payment_hash, None);
|
||||
assert_eq!(payment_id, ev_payment_id);
|
||||
assert_eq!(reason, Some(PaymentFailureReason::UnknownRequiredFeatures));
|
||||
},
|
||||
_ => panic!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignore_unexpected_static_invoice() {
|
||||
// Test that we'll ignore unexpected static invoices, invoices that don't match our invoice
|
||||
// request, and duplicate invoices.
|
||||
let secp_ctx = Secp256k1::new();
|
||||
let chanmon_cfgs = create_chanmon_cfgs(3);
|
||||
let node_cfgs = create_node_cfgs(3, &chanmon_cfgs);
|
||||
let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]);
|
||||
let nodes = create_network(3, &node_cfgs, &node_chanmgrs);
|
||||
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0);
|
||||
create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0);
|
||||
|
||||
// Initiate payment to the sender's intended offer.
|
||||
let blinded_paths_to_always_online_node = nodes[1]
|
||||
.message_router
|
||||
.create_blinded_paths(
|
||||
nodes[1].node.get_our_node_id(),
|
||||
MessageContext::Offers(OffersContext::InvoiceRequest { nonce: Nonce([42; 16]) }),
|
||||
Vec::new(),
|
||||
&secp_ctx,
|
||||
)
|
||||
.unwrap();
|
||||
let (offer_builder, offer_nonce) = nodes[2]
|
||||
.node
|
||||
.create_async_receive_offer_builder(blinded_paths_to_always_online_node.clone())
|
||||
.unwrap();
|
||||
let offer = offer_builder.build().unwrap();
|
||||
let amt_msat = 5000;
|
||||
let payment_id = PaymentId([1; 32]);
|
||||
nodes[0]
|
||||
.node
|
||||
.pay_for_offer(&offer, None, Some(amt_msat), None, payment_id, Retry::Attempts(0), None)
|
||||
.unwrap();
|
||||
|
||||
// Don't forward the invreq since we don't support retrieving the static invoice from the
|
||||
// recipient's LSP yet, instead manually construct the responses below.
|
||||
let invreq_om = nodes[0]
|
||||
.onion_messenger
|
||||
.next_onion_message_for_peer(nodes[1].node.get_our_node_id())
|
||||
.unwrap();
|
||||
let invreq_reply_path = offers_tests::extract_invoice_request(&nodes[1], &invreq_om).1;
|
||||
|
||||
// Create a static invoice to be sent over the reply path containing the original payment_id, but
|
||||
// the static invoice corresponds to a different offer than was originally paid.
|
||||
let unexpected_static_invoice = {
|
||||
let (offer_builder, nonce) = nodes[2]
|
||||
.node
|
||||
.create_async_receive_offer_builder(blinded_paths_to_always_online_node)
|
||||
.unwrap();
|
||||
let sender_unintended_offer = offer_builder.build().unwrap();
|
||||
|
||||
nodes[2]
|
||||
.node
|
||||
.create_static_invoice_builder(&sender_unintended_offer, nonce, None)
|
||||
.unwrap()
|
||||
.build_and_sign(&secp_ctx)
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
// Check that we'll ignore the unexpected static invoice.
|
||||
nodes[1]
|
||||
.onion_messenger
|
||||
.send_onion_message(
|
||||
ParsedOnionMessageContents::<Infallible>::Offers(OffersMessage::StaticInvoice(
|
||||
unexpected_static_invoice,
|
||||
)),
|
||||
MessageSendInstructions::WithoutReplyPath {
|
||||
destination: Destination::BlindedPath(invreq_reply_path.clone()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let unexpected_static_invoice_om = nodes[1]
|
||||
.onion_messenger
|
||||
.next_onion_message_for_peer(nodes[0].node.get_our_node_id())
|
||||
.unwrap();
|
||||
nodes[0]
|
||||
.onion_messenger
|
||||
.handle_onion_message(nodes[1].node.get_our_node_id(), &unexpected_static_invoice_om);
|
||||
let async_pmts_msgs = AsyncPaymentsMessageHandler::release_pending_messages(nodes[0].node);
|
||||
assert!(async_pmts_msgs.is_empty());
|
||||
assert!(nodes[0].node.get_and_clear_pending_events().is_empty());
|
||||
|
||||
// A valid static invoice corresponding to the correct offer will succeed and cause us to send a
|
||||
// held_htlc_available onion message.
|
||||
let valid_static_invoice = nodes[2]
|
||||
.node
|
||||
.create_static_invoice_builder(&offer, offer_nonce, None)
|
||||
.unwrap()
|
||||
.build_and_sign(&secp_ctx)
|
||||
.unwrap();
|
||||
|
||||
nodes[1]
|
||||
.onion_messenger
|
||||
.send_onion_message(
|
||||
ParsedOnionMessageContents::<Infallible>::Offers(OffersMessage::StaticInvoice(
|
||||
valid_static_invoice.clone(),
|
||||
)),
|
||||
MessageSendInstructions::WithoutReplyPath {
|
||||
destination: Destination::BlindedPath(invreq_reply_path.clone()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let static_invoice_om = nodes[1]
|
||||
.onion_messenger
|
||||
.next_onion_message_for_peer(nodes[0].node.get_our_node_id())
|
||||
.unwrap();
|
||||
nodes[0]
|
||||
.onion_messenger
|
||||
.handle_onion_message(nodes[1].node.get_our_node_id(), &static_invoice_om);
|
||||
let async_pmts_msgs = AsyncPaymentsMessageHandler::release_pending_messages(nodes[0].node);
|
||||
assert!(!async_pmts_msgs.is_empty());
|
||||
assert!(async_pmts_msgs
|
||||
.into_iter()
|
||||
.all(|(msg, _)| matches!(msg, AsyncPaymentsMessage::HeldHtlcAvailable(_))));
|
||||
|
||||
// Receiving a duplicate invoice will have no effect.
|
||||
nodes[1]
|
||||
.onion_messenger
|
||||
.send_onion_message(
|
||||
ParsedOnionMessageContents::<Infallible>::Offers(OffersMessage::StaticInvoice(
|
||||
valid_static_invoice,
|
||||
)),
|
||||
MessageSendInstructions::WithoutReplyPath {
|
||||
destination: Destination::BlindedPath(invreq_reply_path),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let dup_static_invoice_om = nodes[1]
|
||||
.onion_messenger
|
||||
.next_onion_message_for_peer(nodes[0].node.get_our_node_id())
|
||||
.unwrap();
|
||||
nodes[0]
|
||||
.onion_messenger
|
||||
.handle_onion_message(nodes[1].node.get_our_node_id(), &dup_static_invoice_om);
|
||||
let async_pmts_msgs = AsyncPaymentsMessageHandler::release_pending_messages(nodes[0].node);
|
||||
assert!(async_pmts_msgs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pays_static_invoice() {
|
||||
// Test that we support the async payments flow up to and including sending the actual payment.
|
||||
// Async receive is not yet supported so we don't complete the payment yet.
|
||||
let secp_ctx = Secp256k1::new();
|
||||
let chanmon_cfgs = create_chanmon_cfgs(3);
|
||||
let node_cfgs = create_node_cfgs(3, &chanmon_cfgs);
|
||||
let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]);
|
||||
let nodes = create_network(3, &node_cfgs, &node_chanmgrs);
|
||||
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0);
|
||||
create_unannounced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0);
|
||||
|
||||
let blinded_paths_to_always_online_node = nodes[1]
|
||||
.message_router
|
||||
.create_blinded_paths(
|
||||
nodes[1].node.get_our_node_id(),
|
||||
MessageContext::Offers(OffersContext::InvoiceRequest { nonce: Nonce([42; 16]) }),
|
||||
Vec::new(),
|
||||
&secp_ctx,
|
||||
)
|
||||
.unwrap();
|
||||
let (offer_builder, offer_nonce) = nodes[2]
|
||||
.node
|
||||
.create_async_receive_offer_builder(blinded_paths_to_always_online_node)
|
||||
.unwrap();
|
||||
let offer = offer_builder.build().unwrap();
|
||||
let amt_msat = 5000;
|
||||
let payment_id = PaymentId([1; 32]);
|
||||
let relative_expiry = Duration::from_secs(1000);
|
||||
let static_invoice = nodes[2]
|
||||
.node
|
||||
.create_static_invoice_builder(&offer, offer_nonce, Some(relative_expiry))
|
||||
.unwrap()
|
||||
.build_and_sign(&secp_ctx)
|
||||
.unwrap();
|
||||
assert!(static_invoice.invoice_features().supports_basic_mpp());
|
||||
assert_eq!(static_invoice.relative_expiry(), relative_expiry);
|
||||
|
||||
nodes[0]
|
||||
.node
|
||||
.pay_for_offer(&offer, None, Some(amt_msat), None, payment_id, Retry::Attempts(0), None)
|
||||
.unwrap();
|
||||
|
||||
// Don't forward the invreq since we don't support retrieving the static invoice from the
|
||||
// recipient's LSP yet, instead manually construct the response.
|
||||
let invreq_om = nodes[0]
|
||||
.onion_messenger
|
||||
.next_onion_message_for_peer(nodes[1].node.get_our_node_id())
|
||||
.unwrap();
|
||||
let invreq_reply_path = offers_tests::extract_invoice_request(&nodes[1], &invreq_om).1;
|
||||
|
||||
nodes[1]
|
||||
.onion_messenger
|
||||
.send_onion_message(
|
||||
ParsedOnionMessageContents::<Infallible>::Offers(OffersMessage::StaticInvoice(
|
||||
static_invoice,
|
||||
)),
|
||||
MessageSendInstructions::WithoutReplyPath {
|
||||
destination: Destination::BlindedPath(invreq_reply_path),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let static_invoice_om = nodes[1]
|
||||
.onion_messenger
|
||||
.next_onion_message_for_peer(nodes[0].node.get_our_node_id())
|
||||
.unwrap();
|
||||
nodes[0]
|
||||
.onion_messenger
|
||||
.handle_onion_message(nodes[1].node.get_our_node_id(), &static_invoice_om);
|
||||
let mut async_pmts_msgs = AsyncPaymentsMessageHandler::release_pending_messages(nodes[0].node);
|
||||
assert!(!async_pmts_msgs.is_empty());
|
||||
assert!(async_pmts_msgs
|
||||
.iter()
|
||||
.all(|(msg, _)| matches!(msg, AsyncPaymentsMessage::HeldHtlcAvailable(_))));
|
||||
|
||||
// Manually send the message and context releasing the HTLC since the recipient doesn't support
|
||||
// responding themselves yet.
|
||||
let held_htlc_avail_reply_path = match async_pmts_msgs.pop().unwrap().1 {
|
||||
MessageSendInstructions::WithSpecifiedReplyPath { reply_path, .. } => reply_path,
|
||||
_ => panic!(),
|
||||
};
|
||||
nodes[2]
|
||||
.onion_messenger
|
||||
.send_onion_message(
|
||||
ParsedOnionMessageContents::<Infallible>::AsyncPayments(
|
||||
AsyncPaymentsMessage::ReleaseHeldHtlc(ReleaseHeldHtlc {}),
|
||||
),
|
||||
MessageSendInstructions::WithoutReplyPath {
|
||||
destination: Destination::BlindedPath(held_htlc_avail_reply_path),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let release_held_htlc_om = nodes[2]
|
||||
.onion_messenger
|
||||
.next_onion_message_for_peer(nodes[0].node.get_our_node_id())
|
||||
.unwrap();
|
||||
nodes[0]
|
||||
.onion_messenger
|
||||
.handle_onion_message(nodes[2].node.get_our_node_id(), &release_held_htlc_om);
|
||||
|
||||
// Check that we've queued the HTLCs of the async keysend payment.
|
||||
let htlc_updates = get_htlc_update_msgs!(nodes[0], nodes[1].node.get_our_node_id());
|
||||
assert_eq!(htlc_updates.update_add_htlcs.len(), 1);
|
||||
check_added_monitors!(nodes[0], 1);
|
||||
|
||||
// Receiving a duplicate release_htlc message doesn't result in duplicate payment.
|
||||
nodes[0]
|
||||
.onion_messenger
|
||||
.handle_onion_message(nodes[2].node.get_our_node_id(), &release_held_htlc_om);
|
||||
assert!(nodes[0].node.get_and_clear_pending_msg_events().is_empty());
|
||||
}
|
|
@ -36,12 +36,8 @@ use crate::util::config::UserConfig;
|
|||
use crate::util::ser::WithoutLength;
|
||||
use crate::util::test_utils;
|
||||
use lightning_invoice::RawBolt11Invoice;
|
||||
#[cfg(async_payments)] use {
|
||||
crate::ln::inbound_payment,
|
||||
crate::types::payment::PaymentPreimage,
|
||||
};
|
||||
|
||||
fn blinded_payment_path(
|
||||
pub fn blinded_payment_path(
|
||||
payment_secret: PaymentSecret, intro_node_min_htlc: u64, intro_node_max_htlc: u64,
|
||||
node_ids: Vec<PublicKey>, channel_upds: &[&msgs::UnsignedChannelUpdate],
|
||||
keys_manager: &test_utils::TestKeysInterface
|
||||
|
@ -1226,149 +1222,6 @@ fn conditionally_round_fwd_amt() {
|
|||
expect_payment_sent(&nodes[0], payment_preimage, Some(Some(expected_fee)), true, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(async_payments)]
|
||||
fn blinded_keysend() {
|
||||
let chanmon_cfgs = create_chanmon_cfgs(3);
|
||||
let node_cfgs = create_node_cfgs(3, &chanmon_cfgs);
|
||||
let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]);
|
||||
let mut nodes = create_network(3, &node_cfgs, &node_chanmgrs);
|
||||
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0);
|
||||
let chan_upd_1_2 = create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0).0.contents;
|
||||
|
||||
let inbound_payment_key = nodes[2].keys_manager.get_inbound_payment_key();
|
||||
let payment_secret = inbound_payment::create_for_spontaneous_payment(
|
||||
&inbound_payment_key, None, u32::MAX, nodes[2].node.duration_since_epoch().as_secs(), None
|
||||
).unwrap();
|
||||
|
||||
let amt_msat = 5000;
|
||||
let keysend_preimage = PaymentPreimage([42; 32]);
|
||||
let route_params = get_blinded_route_parameters(amt_msat, payment_secret, 1,
|
||||
1_0000_0000,
|
||||
nodes.iter().skip(1).map(|n| n.node.get_our_node_id()).collect(),
|
||||
&[&chan_upd_1_2], &chanmon_cfgs[2].keys_manager);
|
||||
|
||||
let payment_hash = nodes[0].node.send_spontaneous_payment(Some(keysend_preimage), RecipientOnionFields::spontaneous_empty(), PaymentId(keysend_preimage.0), route_params, Retry::Attempts(0)).unwrap();
|
||||
check_added_monitors(&nodes[0], 1);
|
||||
|
||||
let expected_route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]];
|
||||
let mut events = nodes[0].node.get_and_clear_pending_msg_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
let ev = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events);
|
||||
pass_along_path(&nodes[0], expected_route[0], amt_msat, payment_hash, Some(payment_secret), ev.clone(), true, Some(keysend_preimage));
|
||||
claim_payment_along_route(
|
||||
ClaimAlongRouteArgs::new(&nodes[0], expected_route, keysend_preimage)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(async_payments)]
|
||||
fn blinded_mpp_keysend() {
|
||||
let chanmon_cfgs = create_chanmon_cfgs(4);
|
||||
let node_cfgs = create_node_cfgs(4, &chanmon_cfgs);
|
||||
let node_chanmgrs = create_node_chanmgrs(4, &node_cfgs, &[None, None, None, None]);
|
||||
let nodes = create_network(4, &node_cfgs, &node_chanmgrs);
|
||||
|
||||
create_announced_chan_between_nodes(&nodes, 0, 1);
|
||||
create_announced_chan_between_nodes(&nodes, 0, 2);
|
||||
let chan_1_3 = create_announced_chan_between_nodes(&nodes, 1, 3);
|
||||
let chan_2_3 = create_announced_chan_between_nodes(&nodes, 2, 3);
|
||||
|
||||
let inbound_payment_key = nodes[3].keys_manager.get_inbound_payment_key();
|
||||
let payment_secret = inbound_payment::create_for_spontaneous_payment(
|
||||
&inbound_payment_key, None, u32::MAX, nodes[3].node.duration_since_epoch().as_secs(), None
|
||||
).unwrap();
|
||||
|
||||
let amt_msat = 15_000_000;
|
||||
let keysend_preimage = PaymentPreimage([42; 32]);
|
||||
let route_params = {
|
||||
let pay_params = PaymentParameters::blinded(
|
||||
vec![
|
||||
blinded_payment_path(payment_secret, 1, 1_0000_0000,
|
||||
vec![nodes[1].node.get_our_node_id(), nodes[3].node.get_our_node_id()], &[&chan_1_3.0.contents],
|
||||
&chanmon_cfgs[3].keys_manager
|
||||
),
|
||||
blinded_payment_path(payment_secret, 1, 1_0000_0000,
|
||||
vec![nodes[2].node.get_our_node_id(), nodes[3].node.get_our_node_id()], &[&chan_2_3.0.contents],
|
||||
&chanmon_cfgs[3].keys_manager
|
||||
),
|
||||
]
|
||||
)
|
||||
.with_bolt12_features(channelmanager::provided_bolt12_invoice_features(&UserConfig::default()))
|
||||
.unwrap();
|
||||
RouteParameters::from_payment_params_and_value(pay_params, amt_msat)
|
||||
};
|
||||
|
||||
let payment_hash = nodes[0].node.send_spontaneous_payment(Some(keysend_preimage), RecipientOnionFields::spontaneous_empty(), PaymentId(keysend_preimage.0), route_params, Retry::Attempts(0)).unwrap();
|
||||
check_added_monitors!(nodes[0], 2);
|
||||
|
||||
let expected_route: &[&[&Node]] = &[&[&nodes[1], &nodes[3]], &[&nodes[2], &nodes[3]]];
|
||||
let mut events = nodes[0].node.get_and_clear_pending_msg_events();
|
||||
assert_eq!(events.len(), 2);
|
||||
|
||||
let ev = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events);
|
||||
pass_along_path(&nodes[0], expected_route[0], amt_msat, payment_hash.clone(),
|
||||
Some(payment_secret), ev.clone(), false, Some(keysend_preimage));
|
||||
|
||||
let ev = remove_first_msg_event_to_node(&nodes[2].node.get_our_node_id(), &mut events);
|
||||
pass_along_path(&nodes[0], expected_route[1], amt_msat, payment_hash.clone(),
|
||||
Some(payment_secret), ev.clone(), true, Some(keysend_preimage));
|
||||
claim_payment_along_route(
|
||||
ClaimAlongRouteArgs::new(&nodes[0], expected_route, keysend_preimage)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(async_payments)]
|
||||
fn invalid_keysend_payment_secret() {
|
||||
let chanmon_cfgs = create_chanmon_cfgs(3);
|
||||
let node_cfgs = create_node_cfgs(3, &chanmon_cfgs);
|
||||
let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]);
|
||||
let mut nodes = create_network(3, &node_cfgs, &node_chanmgrs);
|
||||
create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0);
|
||||
let chan_upd_1_2 = create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0).0.contents;
|
||||
|
||||
let invalid_payment_secret = PaymentSecret([42; 32]);
|
||||
let amt_msat = 5000;
|
||||
let keysend_preimage = PaymentPreimage([42; 32]);
|
||||
let route_params = get_blinded_route_parameters(
|
||||
amt_msat, invalid_payment_secret, 1, 1_0000_0000,
|
||||
nodes.iter().skip(1).map(|n| n.node.get_our_node_id()).collect(), &[&chan_upd_1_2],
|
||||
&chanmon_cfgs[2].keys_manager
|
||||
);
|
||||
|
||||
let payment_hash = nodes[0].node.send_spontaneous_payment(Some(keysend_preimage), RecipientOnionFields::spontaneous_empty(), PaymentId(keysend_preimage.0), route_params, Retry::Attempts(0)).unwrap();
|
||||
check_added_monitors(&nodes[0], 1);
|
||||
|
||||
let expected_route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]];
|
||||
let mut events = nodes[0].node.get_and_clear_pending_msg_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
let ev = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events);
|
||||
let args = PassAlongPathArgs::new(
|
||||
&nodes[0], &expected_route[0], amt_msat, payment_hash, ev.clone()
|
||||
)
|
||||
.with_payment_secret(invalid_payment_secret)
|
||||
.with_payment_preimage(keysend_preimage)
|
||||
.expect_failure(HTLCDestination::FailedPayment { payment_hash });
|
||||
do_pass_along_path(args);
|
||||
|
||||
let updates_2_1 = get_htlc_update_msgs!(nodes[2], nodes[1].node.get_our_node_id());
|
||||
assert_eq!(updates_2_1.update_fail_malformed_htlcs.len(), 1);
|
||||
let update_malformed = &updates_2_1.update_fail_malformed_htlcs[0];
|
||||
assert_eq!(update_malformed.sha256_of_onion, [0; 32]);
|
||||
assert_eq!(update_malformed.failure_code, INVALID_ONION_BLINDING);
|
||||
nodes[1].node.handle_update_fail_malformed_htlc(nodes[2].node.get_our_node_id(), update_malformed);
|
||||
do_commitment_signed_dance(&nodes[1], &nodes[2], &updates_2_1.commitment_signed, true, false);
|
||||
|
||||
let updates_1_0 = get_htlc_update_msgs!(nodes[1], nodes[0].node.get_our_node_id());
|
||||
assert_eq!(updates_1_0.update_fail_htlcs.len(), 1);
|
||||
nodes[0].node.handle_update_fail_htlc(nodes[1].node.get_our_node_id(), &updates_1_0.update_fail_htlcs[0]);
|
||||
do_commitment_signed_dance(&nodes[0], &nodes[1], &updates_1_0.commitment_signed, false, false);
|
||||
expect_payment_failed_conditions(&nodes[0], payment_hash, false,
|
||||
PaymentFailedConditions::new().expected_htlc_error_data(INVALID_ONION_BLINDING, &[0; 32]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_tlvs_to_blinded_path() {
|
||||
|
|
|
@ -74,8 +74,6 @@ use crate::offers::offer::{Offer, OfferBuilder};
|
|||
use crate::offers::parse::Bolt12SemanticError;
|
||||
use crate::offers::refund::{Refund, RefundBuilder};
|
||||
use crate::offers::signer;
|
||||
#[cfg(async_payments)]
|
||||
use crate::offers::static_invoice::StaticInvoice;
|
||||
use crate::onion_message::async_payments::{AsyncPaymentsMessage, HeldHtlcAvailable, ReleaseHeldHtlc, AsyncPaymentsMessageHandler};
|
||||
use crate::onion_message::dns_resolution::HumanReadableName;
|
||||
use crate::onion_message::messenger::{Destination, MessageRouter, Responder, ResponseInstruction, MessageSendInstructions};
|
||||
|
@ -90,6 +88,11 @@ use crate::util::ser::{BigSize, FixedLengthReader, Readable, ReadableArgs, Maybe
|
|||
use crate::util::ser::TransactionU16LenLimited;
|
||||
use crate::util::logger::{Level, Logger, WithContext};
|
||||
use crate::util::errors::APIError;
|
||||
#[cfg(async_payments)] use {
|
||||
crate::blinded_path::payment::AsyncBolt12OfferContext,
|
||||
crate::offers::offer::Amount,
|
||||
crate::offers::static_invoice::{DEFAULT_RELATIVE_EXPIRY as STATIC_INVOICE_DEFAULT_RELATIVE_EXPIRY, StaticInvoice, StaticInvoiceBuilder},
|
||||
};
|
||||
|
||||
#[cfg(feature = "dnssec")]
|
||||
use crate::blinded_path::message::DNSResolverContext;
|
||||
|
@ -6240,11 +6243,16 @@ where
|
|||
match claimable_htlc.onion_payload {
|
||||
OnionPayload::Invoice { .. } => {
|
||||
let payment_data = payment_data.unwrap();
|
||||
let purpose = events::PaymentPurpose::from_parts(
|
||||
let purpose = match events::PaymentPurpose::from_parts(
|
||||
payment_preimage,
|
||||
payment_data.payment_secret,
|
||||
payment_context,
|
||||
);
|
||||
) {
|
||||
Ok(purpose) => purpose,
|
||||
Err(()) => {
|
||||
fail_htlc!(claimable_htlc, payment_hash);
|
||||
},
|
||||
};
|
||||
check_total_value!(purpose);
|
||||
},
|
||||
OnionPayload::Spontaneous(preimage) => {
|
||||
|
@ -9944,6 +9952,86 @@ where
|
|||
#[cfg(c_bindings)]
|
||||
create_refund_builder!(self, RefundMaybeWithDerivedMetadataBuilder);
|
||||
|
||||
/// Create an offer for receiving async payments as an often-offline recipient.
|
||||
///
|
||||
/// Because we may be offline when the payer attempts to request an invoice, you MUST:
|
||||
/// 1. Provide at least 1 [`BlindedMessagePath`] terminating at an always-online node that will
|
||||
/// serve the [`StaticInvoice`] created from this offer on our behalf.
|
||||
/// 2. Use [`Self::create_static_invoice_builder`] to create a [`StaticInvoice`] from this
|
||||
/// [`Offer`] plus the returned [`Nonce`], and provide the static invoice to the
|
||||
/// aforementioned always-online node.
|
||||
#[cfg(async_payments)]
|
||||
pub fn create_async_receive_offer_builder(
|
||||
&self, message_paths_to_always_online_node: Vec<BlindedMessagePath>
|
||||
) -> Result<(OfferBuilder<DerivedMetadata, secp256k1::All>, Nonce), Bolt12SemanticError> {
|
||||
if message_paths_to_always_online_node.is_empty() {
|
||||
return Err(Bolt12SemanticError::MissingPaths)
|
||||
}
|
||||
|
||||
let node_id = self.get_our_node_id();
|
||||
let expanded_key = &self.inbound_payment_key;
|
||||
let entropy = &*self.entropy_source;
|
||||
let secp_ctx = &self.secp_ctx;
|
||||
|
||||
let nonce = Nonce::from_entropy_source(entropy);
|
||||
let mut builder = OfferBuilder::deriving_signing_pubkey(
|
||||
node_id, expanded_key, nonce, secp_ctx
|
||||
).chain_hash(self.chain_hash);
|
||||
|
||||
for path in message_paths_to_always_online_node {
|
||||
builder = builder.path(path);
|
||||
}
|
||||
|
||||
Ok((builder.into(), nonce))
|
||||
}
|
||||
|
||||
/// Creates a [`StaticInvoiceBuilder`] from the corresponding [`Offer`] and [`Nonce`] that were
|
||||
/// created via [`Self::create_async_receive_offer_builder`]. If `relative_expiry` is unset, the
|
||||
/// invoice's expiry will default to [`STATIC_INVOICE_DEFAULT_RELATIVE_EXPIRY`].
|
||||
#[cfg(async_payments)]
|
||||
pub fn create_static_invoice_builder<'a>(
|
||||
&self, offer: &'a Offer, offer_nonce: Nonce, relative_expiry: Option<Duration>
|
||||
) -> Result<StaticInvoiceBuilder<'a>, Bolt12SemanticError> {
|
||||
let expanded_key = &self.inbound_payment_key;
|
||||
let entropy = &*self.entropy_source;
|
||||
let secp_ctx = &self.secp_ctx;
|
||||
|
||||
let payment_context = PaymentContext::AsyncBolt12Offer(
|
||||
AsyncBolt12OfferContext { offer_nonce }
|
||||
);
|
||||
let amount_msat = offer.amount().and_then(|amount| {
|
||||
match amount {
|
||||
Amount::Bitcoin { amount_msats } => Some(amount_msats),
|
||||
Amount::Currency { .. } => None
|
||||
}
|
||||
});
|
||||
|
||||
let relative_expiry = relative_expiry.unwrap_or(STATIC_INVOICE_DEFAULT_RELATIVE_EXPIRY);
|
||||
let relative_expiry_secs: u32 = relative_expiry.as_secs().try_into().unwrap_or(u32::MAX);
|
||||
|
||||
let created_at = self.duration_since_epoch();
|
||||
let payment_secret = inbound_payment::create_for_spontaneous_payment(
|
||||
&self.inbound_payment_key, amount_msat, relative_expiry_secs, created_at.as_secs(), None
|
||||
).map_err(|()| Bolt12SemanticError::InvalidAmount)?;
|
||||
|
||||
let payment_paths = self.create_blinded_payment_paths(
|
||||
amount_msat, payment_secret, payment_context, relative_expiry_secs
|
||||
).map_err(|()| Bolt12SemanticError::MissingPaths)?;
|
||||
|
||||
let nonce = Nonce::from_entropy_source(entropy);
|
||||
let hmac = signer::hmac_for_held_htlc_available_context(nonce, expanded_key);
|
||||
let context = MessageContext::AsyncPayments(
|
||||
AsyncPaymentsContext::InboundPayment { nonce, hmac }
|
||||
);
|
||||
let async_receive_message_paths = self.create_blinded_paths(context)
|
||||
.map_err(|()| Bolt12SemanticError::MissingPaths)?;
|
||||
|
||||
StaticInvoiceBuilder::for_offer_using_derived_keys(
|
||||
offer, payment_paths, async_receive_message_paths, created_at, expanded_key,
|
||||
offer_nonce, secp_ctx
|
||||
).map(|inv| inv.allow_mpp().relative_expiry(relative_expiry_secs))
|
||||
}
|
||||
|
||||
/// Pays for an [`Offer`] using the given parameters by creating an [`InvoiceRequest`] and
|
||||
/// enqueuing it to be sent via an onion message. [`ChannelManager`] will pay the actual
|
||||
/// [`Bolt12Invoice`] once it is received.
|
||||
|
@ -10007,6 +10095,7 @@ where
|
|||
let retryable_invoice_request = RetryableInvoiceRequest {
|
||||
invoice_request: invoice_request.clone(),
|
||||
nonce,
|
||||
needs_retry: true,
|
||||
};
|
||||
self.pending_outbound_payments
|
||||
.add_new_awaiting_invoice(
|
||||
|
@ -10142,7 +10231,7 @@ where
|
|||
Ok((payment_hash, payment_secret)) => {
|
||||
let payment_context = PaymentContext::Bolt12Refund(Bolt12RefundContext {});
|
||||
let payment_paths = self.create_blinded_payment_paths(
|
||||
amount_msats, payment_secret, payment_context
|
||||
Some(amount_msats), payment_secret, payment_context, relative_expiry,
|
||||
)
|
||||
.map_err(|_| Bolt12SemanticError::MissingPaths)?;
|
||||
|
||||
|
@ -10449,7 +10538,8 @@ where
|
|||
/// Creates multi-hop blinded payment paths for the given `amount_msats` by delegating to
|
||||
/// [`Router::create_blinded_payment_paths`].
|
||||
fn create_blinded_payment_paths(
|
||||
&self, amount_msats: u64, payment_secret: PaymentSecret, payment_context: PaymentContext
|
||||
&self, amount_msats: Option<u64>, payment_secret: PaymentSecret, payment_context: PaymentContext,
|
||||
relative_expiry_seconds: u32
|
||||
) -> Result<Vec<BlindedPaymentPath>, ()> {
|
||||
let expanded_key = &self.inbound_payment_key;
|
||||
let entropy = &*self.entropy_source;
|
||||
|
@ -10457,8 +10547,13 @@ where
|
|||
|
||||
let first_hops = self.list_usable_channels();
|
||||
let payee_node_id = self.get_our_node_id();
|
||||
let max_cltv_expiry = self.best_block.read().unwrap().height + CLTV_FAR_FAR_AWAY
|
||||
+ LATENCY_GRACE_PERIOD_BLOCKS;
|
||||
|
||||
// Assume shorter than usual block times to avoid spuriously failing payments too early.
|
||||
const SECONDS_PER_BLOCK: u32 = 9 * 60;
|
||||
let relative_expiry_blocks = relative_expiry_seconds / SECONDS_PER_BLOCK;
|
||||
let max_cltv_expiry = core::cmp::max(relative_expiry_blocks, CLTV_FAR_FAR_AWAY)
|
||||
.saturating_add(LATENCY_GRACE_PERIOD_BLOCKS)
|
||||
.saturating_add(self.best_block.read().unwrap().height);
|
||||
|
||||
let payee_tlvs = UnauthenticatedReceiveTlvs {
|
||||
payment_secret,
|
||||
|
@ -11871,7 +11966,7 @@ where
|
|||
.pending_outbound_payments
|
||||
.release_invoice_requests_awaiting_invoice()
|
||||
{
|
||||
let RetryableInvoiceRequest { invoice_request, nonce } = retryable_invoice_request;
|
||||
let RetryableInvoiceRequest { invoice_request, nonce, .. } = retryable_invoice_request;
|
||||
let hmac = payment_id.hmac_for_offer_payment(nonce, &self.inbound_payment_key);
|
||||
let context = MessageContext::Offers(OffersContext::OutboundPayment {
|
||||
payment_id,
|
||||
|
@ -12003,7 +12098,7 @@ where
|
|||
invoice_request: invoice_request.fields(),
|
||||
});
|
||||
let payment_paths = match self.create_blinded_payment_paths(
|
||||
amount_msats, payment_secret, payment_context
|
||||
Some(amount_msats), payment_secret, payment_context, relative_expiry
|
||||
) {
|
||||
Ok(payment_paths) => payment_paths,
|
||||
Err(()) => {
|
||||
|
@ -12153,7 +12248,12 @@ where
|
|||
|
||||
fn handle_release_held_htlc(&self, _message: ReleaseHeldHtlc, _context: AsyncPaymentsContext) {
|
||||
#[cfg(async_payments)] {
|
||||
let AsyncPaymentsContext::OutboundPayment { payment_id, hmac, nonce } = _context;
|
||||
let (payment_id, nonce, hmac) = match _context {
|
||||
AsyncPaymentsContext::OutboundPayment { payment_id, hmac, nonce } => {
|
||||
(payment_id, nonce, hmac)
|
||||
},
|
||||
_ => return
|
||||
};
|
||||
if payment_id.verify_for_async_payment(hmac, nonce, &self.inbound_payment_key).is_err() { return }
|
||||
if let Err(e) = self.send_payment_for_static_invoice(payment_id) {
|
||||
log_trace!(
|
||||
|
@ -12206,6 +12306,7 @@ where
|
|||
let retryable_invoice_request = RetryableInvoiceRequest {
|
||||
invoice_request: invoice_request.clone(),
|
||||
nonce,
|
||||
needs_retry: true,
|
||||
};
|
||||
self.pending_outbound_payments
|
||||
.received_offer(payment_id, Some(retryable_invoice_request))
|
||||
|
|
|
@ -55,6 +55,9 @@ pub use onion_utils::create_payment_onion;
|
|||
#[cfg(test)]
|
||||
#[allow(unused_mut)]
|
||||
mod blinded_payment_tests;
|
||||
#[cfg(all(test, async_payments))]
|
||||
#[allow(unused_mut)]
|
||||
mod async_payments_tests;
|
||||
#[cfg(test)]
|
||||
#[allow(unused_mut)]
|
||||
mod functional_tests;
|
||||
|
|
|
@ -199,7 +199,7 @@ fn extract_offer_nonce<'a, 'b, 'c>(node: &Node<'a, 'b, 'c>, message: &OnionMessa
|
|||
}
|
||||
}
|
||||
|
||||
fn extract_invoice_request<'a, 'b, 'c>(
|
||||
pub(super) fn extract_invoice_request<'a, 'b, 'c>(
|
||||
node: &Node<'a, 'b, 'c>, message: &OnionMessage
|
||||
) -> (InvoiceRequest, BlindedMessagePath) {
|
||||
match node.onion_messenger.peel_onion_message(message) {
|
||||
|
|
|
@ -134,13 +134,16 @@ pub(crate) enum PendingOutboundPayment {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct RetryableInvoiceRequest {
|
||||
pub(crate) invoice_request: InvoiceRequest,
|
||||
pub(crate) nonce: Nonce,
|
||||
pub(super) needs_retry: bool,
|
||||
}
|
||||
|
||||
impl_writeable_tlv_based!(RetryableInvoiceRequest, {
|
||||
(0, invoice_request, required),
|
||||
(1, needs_retry, (default_value, true)),
|
||||
(2, nonce, required),
|
||||
});
|
||||
|
||||
|
@ -760,7 +763,12 @@ pub(super) struct OutboundPayments {
|
|||
impl OutboundPayments {
|
||||
pub(super) fn new(pending_outbound_payments: HashMap<PaymentId, PendingOutboundPayment>) -> Self {
|
||||
let has_invoice_requests = pending_outbound_payments.values().any(|payment| {
|
||||
matches!(payment, PendingOutboundPayment::AwaitingInvoice { retryable_invoice_request: Some(_), .. })
|
||||
matches!(
|
||||
payment,
|
||||
PendingOutboundPayment::AwaitingInvoice {
|
||||
retryable_invoice_request: Some(invreq), ..
|
||||
} if invreq.needs_retry
|
||||
)
|
||||
});
|
||||
|
||||
Self {
|
||||
|
@ -1008,17 +1016,16 @@ impl OutboundPayments {
|
|||
) -> Result<(), Bolt12PaymentError> where ES::Target: EntropySource {
|
||||
macro_rules! abandon_with_entry {
|
||||
($payment: expr, $reason: expr) => {
|
||||
$payment.get_mut().mark_abandoned($reason);
|
||||
if let PendingOutboundPayment::Abandoned { reason, .. } = $payment.get() {
|
||||
if $payment.get().remaining_parts() == 0 {
|
||||
pending_events.lock().unwrap().push_back((events::Event::PaymentFailed {
|
||||
payment_id,
|
||||
payment_hash: None,
|
||||
reason: *reason,
|
||||
}, None));
|
||||
$payment.remove();
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
matches!($payment.get(), PendingOutboundPayment::AwaitingInvoice { .. }),
|
||||
"Generating PaymentFailed for unexpected outbound payment type can result in funds loss"
|
||||
);
|
||||
pending_events.lock().unwrap().push_back((events::Event::PaymentFailed {
|
||||
payment_id,
|
||||
payment_hash: None,
|
||||
reason: Some($reason),
|
||||
}, None));
|
||||
$payment.remove();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2229,11 +2236,12 @@ impl OutboundPayments {
|
|||
.iter_mut()
|
||||
.filter_map(|(payment_id, payment)| {
|
||||
if let PendingOutboundPayment::AwaitingInvoice {
|
||||
retryable_invoice_request, ..
|
||||
retryable_invoice_request: Some(invreq), ..
|
||||
} = payment {
|
||||
retryable_invoice_request.take().map(|retryable_invoice_request| {
|
||||
(*payment_id, retryable_invoice_request)
|
||||
})
|
||||
if invreq.needs_retry {
|
||||
invreq.needs_retry = false;
|
||||
Some((*payment_id, invreq.clone()))
|
||||
} else { None }
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
|
|
@ -50,6 +50,11 @@ const PAYMENT_HASH_HMAC_INPUT: &[u8; 16] = &[7; 16];
|
|||
// HMAC input for `ReceiveTlvs`. The HMAC is used in `blinded_path::payment::PaymentContext`.
|
||||
const PAYMENT_TLVS_HMAC_INPUT: &[u8; 16] = &[8; 16];
|
||||
|
||||
// HMAC input used in `AsyncPaymentsContext::InboundPayment` to authenticate inbound
|
||||
// held_htlc_available onion messages.
|
||||
#[cfg(async_payments)]
|
||||
const ASYNC_PAYMENTS_HELD_HTLC_HMAC_INPUT: &[u8; 16] = &[9; 16];
|
||||
|
||||
/// Message metadata which possibly is derived from [`MetadataMaterial`] such that it can be
|
||||
/// verified.
|
||||
#[derive(Clone)]
|
||||
|
@ -483,3 +488,16 @@ pub(crate) fn verify_payment_tlvs(
|
|||
) -> Result<(), ()> {
|
||||
if hmac_for_payment_tlvs(receive_tlvs, nonce, expanded_key) == hmac { Ok(()) } else { Err(()) }
|
||||
}
|
||||
|
||||
#[cfg(async_payments)]
|
||||
pub(crate) fn hmac_for_held_htlc_available_context(
|
||||
nonce: Nonce, expanded_key: &ExpandedKey,
|
||||
) -> Hmac<Sha256> {
|
||||
const IV_BYTES: &[u8; IV_LEN] = b"LDK Held HTLC OM";
|
||||
let mut hmac = expanded_key.hmac_for_offer();
|
||||
hmac.input(IV_BYTES);
|
||||
hmac.input(&nonce.0);
|
||||
hmac.input(ASYNC_PAYMENTS_HELD_HTLC_HMAC_INPUT);
|
||||
|
||||
Hmac::from_engine(hmac)
|
||||
}
|
||||
|
|
|
@ -49,7 +49,7 @@ use crate::offers::invoice::is_expired;
|
|||
use crate::prelude::*;
|
||||
|
||||
/// Static invoices default to expiring after 2 weeks.
|
||||
const DEFAULT_RELATIVE_EXPIRY: Duration = Duration::from_secs(3600 * 24 * 14);
|
||||
pub const DEFAULT_RELATIVE_EXPIRY: Duration = Duration::from_secs(3600 * 24 * 14);
|
||||
|
||||
/// Tag for the hash function used when signing a [`StaticInvoice`]'s merkle root.
|
||||
pub const SIGNATURE_TAG: &'static str = concat!("lightning", "static_invoice", "signature");
|
||||
|
@ -102,8 +102,8 @@ pub struct StaticInvoiceBuilder<'a> {
|
|||
impl<'a> StaticInvoiceBuilder<'a> {
|
||||
/// Initialize a [`StaticInvoiceBuilder`] from the given [`Offer`].
|
||||
///
|
||||
/// Unless [`StaticInvoiceBuilder::relative_expiry`] is set, the invoice will expire 24 hours
|
||||
/// after `created_at`.
|
||||
/// The invoice's expiration will default to [`DEFAULT_RELATIVE_EXPIRY`] after `created_at` unless
|
||||
/// overridden by [`StaticInvoiceBuilder::relative_expiry`].
|
||||
pub fn for_offer_using_derived_keys<T: secp256k1::Signing>(
|
||||
offer: &'a Offer, payment_paths: Vec<BlindedPaymentPath>,
|
||||
message_paths: Vec<BlindedMessagePath>, created_at: Duration, expanded_key: &ExpandedKey,
|
||||
|
|
|
@ -95,7 +95,7 @@ impl<G: Deref<Target = NetworkGraph<L>>, L: Deref, ES: Deref, S: Deref, SP: Size
|
|||
T: secp256k1::Signing + secp256k1::Verification
|
||||
> (
|
||||
&self, recipient: PublicKey, first_hops: Vec<ChannelDetails>, tlvs: ReceiveTlvs,
|
||||
amount_msats: u64, secp_ctx: &Secp256k1<T>
|
||||
amount_msats: Option<u64>, secp_ctx: &Secp256k1<T>
|
||||
) -> Result<Vec<BlindedPaymentPath>, ()> {
|
||||
// Limit the number of blinded paths that are computed.
|
||||
const MAX_PAYMENT_PATHS: usize = 3;
|
||||
|
@ -120,9 +120,9 @@ impl<G: Deref<Target = NetworkGraph<L>>, L: Deref, ES: Deref, S: Deref, SP: Size
|
|||
|
||||
let paths = first_hops.into_iter()
|
||||
.filter(|details| details.counterparty.features.supports_route_blinding())
|
||||
.filter(|details| amount_msats <= details.inbound_capacity_msat)
|
||||
.filter(|details| amount_msats >= details.inbound_htlc_minimum_msat.unwrap_or(0))
|
||||
.filter(|details| amount_msats <= details.inbound_htlc_maximum_msat.unwrap_or(u64::MAX))
|
||||
.filter(|details| amount_msats.unwrap_or(0) <= details.inbound_capacity_msat)
|
||||
.filter(|details| amount_msats.unwrap_or(u64::MAX) >= details.inbound_htlc_minimum_msat.unwrap_or(0))
|
||||
.filter(|details| amount_msats.unwrap_or(0) <= details.inbound_htlc_maximum_msat.unwrap_or(u64::MAX))
|
||||
// Limit to peers with announced channels unless the recipient is unannounced.
|
||||
.filter(|details| network_graph
|
||||
.node(&NodeId::from_pubkey(&details.counterparty.node_id))
|
||||
|
@ -218,7 +218,7 @@ pub trait Router {
|
|||
T: secp256k1::Signing + secp256k1::Verification
|
||||
> (
|
||||
&self, recipient: PublicKey, first_hops: Vec<ChannelDetails>, tlvs: ReceiveTlvs,
|
||||
amount_msats: u64, secp_ctx: &Secp256k1<T>
|
||||
amount_msats: Option<u64>, secp_ctx: &Secp256k1<T>
|
||||
) -> Result<Vec<BlindedPaymentPath>, ()>;
|
||||
}
|
||||
|
||||
|
|
|
@ -252,7 +252,7 @@ impl<'a> Router for TestRouter<'a> {
|
|||
T: secp256k1::Signing + secp256k1::Verification
|
||||
>(
|
||||
&self, recipient: PublicKey, first_hops: Vec<ChannelDetails>, tlvs: ReceiveTlvs,
|
||||
amount_msats: u64, secp_ctx: &Secp256k1<T>,
|
||||
amount_msats: Option<u64>, secp_ctx: &Secp256k1<T>,
|
||||
) -> Result<Vec<BlindedPaymentPath>, ()> {
|
||||
let mut expected_paths = self.next_blinded_payment_paths.lock().unwrap();
|
||||
if expected_paths.is_empty() {
|
||||
|
|
Loading…
Add table
Reference in a new issue