From b85cad4d8cadf80282dd87dc2a84bffc8e91ebdb Mon Sep 17 00:00:00 2001 From: Orbital Date: Mon, 1 Jan 2024 18:40:40 -0600 Subject: [PATCH] offers: send invoice request --- src/lib.rs | 39 ++++++-- src/lnd.rs | 3 +- src/lndk_offers.rs | 172 ++++++++++++++++++++++++++++++- tests/common/mod.rs | 2 +- tests/integration_tests.rs | 200 ++++++++++++++++++++++++++++++++++++- 5 files changed, 400 insertions(+), 16 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index ad2250cb..4286b634 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,13 +8,17 @@ mod rate_limit; use crate::lnd::{ features_support_onion_messages, get_lnd_client, string_to_network, LndCfg, LndNodeSigner, }; - +use crate::lndk_offers::OfferError; use crate::onion_messenger::MessengerUtilities; -use bitcoin::secp256k1::PublicKey; +use bitcoin::network::constants::Network; +use bitcoin::secp256k1::{Error as Secp256k1Error, PublicKey}; use home::home_dir; +use lightning::blinded_path::BlindedPath; use lightning::ln::peer_handler::IgnoringMessageHandler; +use lightning::offers::offer::Offer; use lightning::onion_message::{ - DefaultMessageRouter, OffersMessage, OffersMessageHandler, OnionMessenger, PendingOnionMessage, + DefaultMessageRouter, Destination, OffersMessage, OffersMessageHandler, OnionMessenger, + PendingOnionMessage, }; use log::{error, info, LevelFilter}; use log4rs::append::console::ConsoleAppender; @@ -24,8 +28,9 @@ use log4rs::encode::pattern::PatternEncoder; use std::collections::HashMap; use std::str::FromStr; use std::sync::{Mutex, Once}; -use tokio::sync::mpsc::Sender; +use tokio::sync::mpsc::{Receiver, Sender}; use tonic_lnd::lnrpc::GetInfoRequest; +use tonic_lnd::Client; use triggered::{Listener, Trigger}; static INIT: Once = Once::new(); @@ -175,19 +180,41 @@ enum OfferState { } pub struct OfferHandler { - _active_offers: Mutex>, + active_offers: Mutex>, pending_messages: Mutex>>, messenger_utils: MessengerUtilities, } +#[derive(Clone)] +pub struct PayOfferParams { + pub offer: Offer, + pub amount: Option, + pub network: Network, + pub client: Client, + /// The destination the offer creator provided, which we will use to send the invoice request. + pub destination: Destination, + /// The path we will send back to the offer creator, so it knows where to send back the invoice. + pub reply_path: Option, +} + impl OfferHandler { pub fn new() -> Self { OfferHandler { - _active_offers: Mutex::new(HashMap::new()), + active_offers: Mutex::new(HashMap::new()), pending_messages: Mutex::new(Vec::new()), messenger_utils: MessengerUtilities::new(), } } + + /// Adds an offer to be paid with the amount specified. May only be called once for a single offer. + pub async fn pay_offer( + &self, + cfg: PayOfferParams, + started: Receiver, + ) -> Result<(), OfferError> { + self.send_invoice_request(cfg, started).await?; + Ok(()) + } } impl Default for OfferHandler { diff --git a/src/lnd.rs b/src/lnd.rs index 58a96d7d..1eb6161b 100644 --- a/src/lnd.rs +++ b/src/lnd.rs @@ -29,6 +29,7 @@ pub(crate) fn get_lnd_client(cfg: LndCfg) -> Result { } /// LndCfg specifies the configuration required to connect to LND's grpc client. +#[derive(Clone)] pub struct LndCfg { address: String, cert: PathBuf, @@ -186,7 +187,7 @@ pub(crate) fn string_to_network(network_str: &str) -> Result Result, Status>; async fn sign_message( &mut self, diff --git a/src/lndk_offers.rs b/src/lndk_offers.rs index 5da9f490..3fa25eb3 100644 --- a/src/lndk_offers.rs +++ b/src/lndk_offers.rs @@ -1,5 +1,5 @@ use crate::lnd::{features_support_onion_messages, MessageSigner, PeerConnector}; -use crate::OfferHandler; +use crate::{OfferHandler, OfferState, PayOfferParams}; use async_trait::async_trait; use bitcoin::hashes::sha256::Hash; use bitcoin::network::constants::Network; @@ -11,12 +11,14 @@ use lightning::offers::invoice_request::{InvoiceRequest, UnsignedInvoiceRequest} use lightning::offers::merkle::SignError; use lightning::offers::offer::{Amount, Offer}; use lightning::offers::parse::{Bolt12ParseError, Bolt12SemanticError}; +use lightning::onion_message::{Destination, OffersMessage, PendingOnionMessage}; use log::error; use std::error::Error; use std::fmt::Display; use std::str::FromStr; +use tokio::sync::mpsc::Receiver; use tokio::task; -use tonic_lnd::lnrpc::{LightningNode, ListPeersRequest, ListPeersResponse}; +use tonic_lnd::lnrpc::{GetInfoRequest, LightningNode, ListPeersRequest, ListPeersResponse}; use tonic_lnd::signrpc::{KeyLocator, SignMessageReq}; use tonic_lnd::tonic::Status; use tonic_lnd::Client; @@ -24,6 +26,8 @@ use tonic_lnd::Client; #[derive(Debug)] /// OfferError is an error that occurs during the process of paying an offer. pub enum OfferError { + /// AlreadyProcessing indicates that we're already in the process of paying an offer. + AlreadyProcessing, /// BuildUIRFailure indicates a failure to build the unsigned invoice request. BuildUIRFailure(Bolt12SemanticError), /// SignError indicates a failure to sign the invoice request. @@ -47,6 +51,9 @@ pub enum OfferError { impl Display for OfferError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + OfferError::AlreadyProcessing => { + write!(f, "LNDK is already trying to pay for provided offer") + } OfferError::BuildUIRFailure(e) => write!(f, "Error building invoice request: {e:?}"), OfferError::SignError(e) => write!(f, "Error signing invoice request: {e:?}"), OfferError::DeriveKeyFailure(e) => write!(f, "Error signing invoice request: {e:?}"), @@ -71,9 +78,76 @@ pub fn decode(offer_str: String) -> Result { } impl OfferHandler { - #[allow(dead_code)] + pub async fn send_invoice_request( + &self, + mut cfg: PayOfferParams, + mut started: Receiver, + ) -> Result<(), OfferError> { + // Wait for onion messenger to give us the signal that it's ready. Once the onion messenger drops + // the channel sender, recv will return None and we'll stop blocking here. + if started.recv().await.is_some() { + error!("Error: we shouldn't receive any messages on this channel"); + } + + let validated_amount = validate_amount(&cfg.offer, cfg.amount).await?; + + // For now we connect directly to the introduction node of the blinded path so we don't need any + // intermediate nodes here. In the future we'll query for a full path to the introduction node for + // better sender privacy. + match cfg.destination { + Destination::Node(pubkey) => connect_to_peer(cfg.client.clone(), pubkey).await?, + Destination::BlindedPath(ref path) => { + connect_to_peer(cfg.client.clone(), path.introduction_node_id).await? + } + }; + + let offer_id = cfg.offer.clone().to_string(); + { + let mut active_offers = self.active_offers.lock().unwrap(); + if active_offers.contains_key(&offer_id.clone()) { + return Err(OfferError::AlreadyProcessing); + } + active_offers.insert(cfg.offer.to_string().clone(), OfferState::OfferAdded); + } + + let invoice_request = self + .create_invoice_request( + cfg.client.clone(), + cfg.offer, + vec![], + cfg.network, + validated_amount, + ) + .await?; + + if cfg.reply_path.is_none() { + let info = cfg + .client + .lightning() + .get_info(GetInfoRequest {}) + .await + .expect("failed to get info") + .into_inner(); + + let pubkey = PublicKey::from_str(&info.identity_pubkey).unwrap(); + cfg.reply_path = Some(self.create_reply_path(cfg.client.clone(), pubkey).await?) + }; + let contents = OffersMessage::InvoiceRequest(invoice_request); + let pending_message = PendingOnionMessage { + contents, + destination: cfg.destination, + reply_path: cfg.reply_path, + }; + + let mut pending_messages = self.pending_messages.lock().unwrap(); + pending_messages.push(pending_message); + std::mem::drop(pending_messages); + + Ok(()) + } + // create_invoice_request builds and signs an invoice request, the first step in the BOLT 12 process of paying an offer. - pub(crate) async fn create_invoice_request( + pub async fn create_invoice_request( &self, mut signer: impl MessageSigner + std::marker::Send + 'static, offer: Offer, @@ -163,7 +237,7 @@ impl OfferHandler { ) } else { Ok(BlindedPath::new_for_message( - &[intro_node.unwrap()], + &[intro_node.unwrap(), node_id], &self.messenger_utils, &secp_ctx, ) @@ -222,6 +296,14 @@ pub async fn validate_amount( Ok(validated_amount) } +pub async fn get_destination(offer: &Offer) -> Destination { + if offer.paths().is_empty() { + Destination::Node(offer.signing_pubkey()) + } else { + Destination::BlindedPath(offer.paths()[0].clone()) + } +} + // connect_to_peer connects to the provided node if we're not already connected. pub async fn connect_to_peer( mut connector: impl PeerConnector, @@ -347,6 +429,10 @@ mod tests { use std::time::{Duration, SystemTime}; use tonic_lnd::lnrpc::NodeAddress; + fn get_offer() -> String { + "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcgqgn3qzsyvfkx26qkyypvr5hfx60h9w9k934lt8s2n6zc0wwtgqlulw7dythr83dqx8tzumg".to_string() + } + fn build_custom_offer(amount_msats: u64) -> Offer { let secp_ctx = Secp256k1::new(); let keys = KeyPair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); @@ -366,6 +452,10 @@ mod tests { "0313ba7ccbd754c117962b9afab6c2870eb3ef43f364a9f6c43d0fabb4553776ba".to_string() } + fn get_signature() -> String { + "28b937976a29c15827433086440b36c2bec6ca5bd977557972dca8641cd59ffba50daafb8ee99a19c950976b46f47d9e7aa716652e5657dfc555b82eff467f18".to_string() + } + mock! { TestBolt12Signer{} @@ -387,6 +477,78 @@ mod tests { } } + #[tokio::test] + async fn test_request_invoice() { + let mut signer_mock = MockTestBolt12Signer::new(); + + signer_mock.expect_derive_key().returning(|_| { + Ok(PublicKey::from_str(&get_pubkey()) + .unwrap() + .serialize() + .to_vec()) + }); + + signer_mock.expect_sign_message().returning(|_, _, _| { + Ok(Signature::from_str(&get_signature()) + .unwrap() + .as_ref() + .to_vec()) + }); + + let offer = decode(get_offer()).unwrap(); + let handler = OfferHandler::new(); + assert!(handler + .create_invoice_request(signer_mock, offer, vec![], Network::Regtest, 10000) + .await + .is_ok()) + } + + #[tokio::test] + async fn test_request_invoice_derive_key_error() { + let mut signer_mock = MockTestBolt12Signer::new(); + + signer_mock + .expect_derive_key() + .returning(|_| Err(Status::unknown("error testing"))); + + signer_mock.expect_sign_message().returning(|_, _, _| { + Ok(Signature::from_str(&get_signature()) + .unwrap() + .as_ref() + .to_vec()) + }); + + let offer = decode(get_offer()).unwrap(); + let handler = OfferHandler::new(); + assert!(handler + .create_invoice_request(signer_mock, offer, vec![], Network::Regtest, 10000) + .await + .is_err()) + } + + #[tokio::test] + async fn test_request_invoice_signer_error() { + let mut signer_mock = MockTestBolt12Signer::new(); + + signer_mock.expect_derive_key().returning(|_| { + Ok(PublicKey::from_str(&get_pubkey()) + .unwrap() + .serialize() + .to_vec()) + }); + + signer_mock + .expect_sign_message() + .returning(|_, _, _| Err(Status::unknown("error testing"))); + + let offer = decode(get_offer()).unwrap(); + let handler = OfferHandler::new(); + assert!(handler + .create_invoice_request(signer_mock, offer, vec![], Network::Regtest, 10000) + .await + .is_err()) + } + #[tokio::test] async fn test_validate_amount() { // If the amount the user provided is greater than the offer-provided amount, then diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 7b65abf1..af7e505e 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -155,7 +155,7 @@ pub struct LndNode { pub cert_path: String, pub macaroon_path: String, _handle: Child, - client: Option, + pub client: Option, } impl LndNode { diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 1af8b77c..7e21ed60 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1,12 +1,20 @@ mod common; use lndk; -use bitcoin::secp256k1::PublicKey; +use bitcoin::secp256k1::{PublicKey, Secp256k1}; +use bitcoin::Network; +use bitcoincore_rpc::bitcoin::Network as RpcNetwork; +use bitcoincore_rpc::RpcApi; use chrono::Utc; use ldk_sample::node_api::Node as LdkNode; -use lndk::LifecycleSignals; +use lightning::blinded_path::BlindedPath; +use lightning::offers::offer::Quantity; +use lightning::onion_message::Destination; +use lndk::onion_messenger::MessengerUtilities; +use lndk::{LifecycleSignals, PayOfferParams}; use std::path::PathBuf; use std::str::FromStr; +use std::time::SystemTime; use tokio::select; use tokio::sync::mpsc; use tokio::sync::mpsc::{Receiver, Sender}; @@ -36,7 +44,6 @@ async fn wait_to_receive_onion_message( async fn check_for_message(ldk: LdkNode) -> LdkNode { loop { if ldk.onion_message_handler.messages.lock().unwrap().len() == 1 { - println!("MESSAGE: {:?}", ldk.onion_message_handler.messages); return ldk; } sleep(Duration::from_secs(2)).await; @@ -102,3 +109,190 @@ async fn test_lndk_forwards_onion_message() { } } } + +#[tokio::test(flavor = "multi_thread")] +// Here we test the beginning of the BOLT 12 offers flow. We show that lndk successfully builds an +// invoice_request and sends it. +async fn test_lndk_send_invoice_request() { + let test_name = "lndk_send_invoice_request"; + let (bitcoind, mut lnd, ldk1, ldk2, lndk_dir) = + common::setup_test_infrastructure(test_name).await; + + // Here we'll produce a little network. ldk1 will be the offer creator in this scenario. We'll + // connect ldk1 and ldk2 with a channel so ldk1 can create an offer and ldk2 can be the + // introduction node for the blinded path. + // + // Later on we'll disconnect lnd to ldk2 to make sure lnd can still auto-connect to the + // introduction node. + // + // ldk1 <--- channel ---> ldk2 <--- peer connection ---> lnd + // + // ldk1 will be the offer creator, which will build a blinded route from ldk2 to ldk1. + let (pubkey, addr) = ldk1.get_node_info(); + let (pubkey_2, addr_2) = ldk2.get_node_info(); + let lnd_info = lnd.get_info().await; + let lnd_pubkey = PublicKey::from_str(&lnd_info.identity_pubkey).unwrap(); + + ldk1.connect_to_peer(pubkey_2, addr_2).await.unwrap(); + lnd.connect_to_peer(pubkey_2, addr_2).await; + + let ldk2_fund_addr = ldk2.bitcoind_client.get_new_address().await; + + // We need to convert funding addresses to the form that the bitcoincore_rpc library recognizes. + let ldk2_addr_string = ldk2_fund_addr.to_string(); + let ldk2_addr = bitcoind::bitcoincore_rpc::bitcoin::Address::from_str(&ldk2_addr_string) + .unwrap() + .require_network(RpcNetwork::Regtest) + .unwrap(); + + // Fund both of these nodes, open the channels, and synchronize the network. + bitcoind + .node + .client + .generate_to_address(6, &ldk2_addr) + .unwrap(); + + lnd.wait_for_chain_sync().await; + + ldk2.open_channel(pubkey, addr, 200000, 0, true) + .await + .unwrap(); + + lnd.wait_for_graph_sync().await; + + bitcoind + .node + .client + .generate_to_address(20, &ldk2_addr) + .unwrap(); + + lnd.wait_for_chain_sync().await; + + let path_pubkeys = vec![pubkey_2, pubkey]; + let expiration = SystemTime::now() + Duration::from_secs(24 * 60 * 60); + let offer = ldk1 + .create_offer( + &path_pubkeys, + Network::Regtest, + 20_000, + Quantity::One, + expiration, + ) + .await + .expect("should create offer"); + + // Now we'll spin up lndk, which should forward the invoice request to ldk2. + let (shutdown, listener) = triggered::trigger(); + let lnd_cfg = lndk::lnd::LndCfg::new( + lnd.address.clone(), + PathBuf::from_str(&lnd.cert_path).unwrap(), + PathBuf::from_str(&lnd.macaroon_path).unwrap(), + ); + let (tx, rx): (Sender, Receiver) = mpsc::channel(1); + let signals = LifecycleSignals { + shutdown: shutdown.clone(), + listener, + started: tx, + }; + + let lndk_cfg = lndk::Cfg { + lnd: lnd_cfg.clone(), + log_dir: Some( + lndk_dir + .join(format!("lndk-logs.txt")) + .to_str() + .unwrap() + .to_string(), + ), + signals, + }; + + let mut client = lnd.client.clone().unwrap(); + let blinded_path = offer.paths()[0].clone(); + + let messenger_utils = MessengerUtilities::new(); + let secp_ctx = Secp256k1::new(); + let reply_path = + BlindedPath::new_for_message(&[pubkey_2, lnd_pubkey], &messenger_utils, &secp_ctx).unwrap(); + + let mut stream = client + .lightning() + .subscribe_channel_graph(tonic_lnd::lnrpc::GraphTopologySubscription {}) + .await + .unwrap() + .into_inner(); + + // Wait for ldk2's graph update to come through, otherwise when we try to auto-connect to + // the introduction node later on, the address won't be available when we call the + // describe_graph API method. + 'outer: while let Some(update) = stream.message().await.unwrap() { + for node in update.node_updates.iter() { + for node_addr in node.node_addresses.iter() { + if node_addr.addr == addr_2.to_string() { + break 'outer; + } + } + } + } + + // Make sure lndk successfully sends the invoice_request. + let handler = lndk::OfferHandler::new(); + let messenger = lndk::LndkOnionMessenger::new(handler); + let pay_cfg = PayOfferParams { + offer: offer.clone(), + amount: Some(20_000), + network: Network::Regtest, + client: client.clone(), + destination: Destination::BlindedPath(blinded_path.clone()), + reply_path: Some(reply_path.clone()), + }; + select! { + val = messenger.run(lndk_cfg) => { + panic!("lndk should not have completed first {:?}", val); + }, + // We wait for ldk2 to receive the onion message. + res = messenger.offer_handler.send_invoice_request(pay_cfg.clone(), rx) => { + assert!(res.is_ok()); + } + } + + // Let's try again, but, make sure we can request the invoice when the LND node is not already connected + // to the introduction node (LDK2). + lnd.disconnect_peer(pubkey_2).await; + lnd.wait_for_chain_sync().await; + + let (shutdown, listener) = triggered::trigger(); + let (tx, rx): (Sender, Receiver) = mpsc::channel(1); + let signals = LifecycleSignals { + shutdown: shutdown.clone(), + listener, + started: tx, + }; + + let lndk_cfg = lndk::Cfg { + lnd: lnd_cfg, + log_dir: Some( + lndk_dir + .join(format!("lndk-logs.txt")) + .to_str() + .unwrap() + .to_string(), + ), + signals, + }; + + let handler = lndk::OfferHandler::new(); + let messenger = lndk::LndkOnionMessenger::new(handler); + select! { + val = messenger.run(lndk_cfg) => { + panic!("lndk should not have completed first {:?}", val); + }, + // We wait for ldk2 to receive the onion message. + res = messenger.offer_handler.send_invoice_request(pay_cfg, rx) => { + assert!(res.is_ok()); + shutdown.trigger(); + ldk1.stop().await; + ldk2.stop().await; + } + } +}