From 559ec3bef0c8f00f0b399aa40781b955fa53dc8a Mon Sep 17 00:00:00 2001 From: Valentine Wallace Date: Thu, 4 May 2023 15:05:13 -0400 Subject: [PATCH] Implement routing to blinded payment paths Sending to them is still disallowed, for now --- lightning/src/routing/router.rs | 333 ++++++++++++++++++++++++++++++-- 1 file changed, 314 insertions(+), 19 deletions(-) diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index d413fafb5f0..12eb837103d 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -620,6 +620,30 @@ impl PaymentParameters { Self::from_node_id(payee_pubkey, final_cltv_expiry_delta).with_bolt11_features(InvoiceFeatures::for_keysend()).expect("PaymentParameters::from_node_id should always initialize the payee as unblinded") } + /// Creates parameters for paying to a blinded payee. + pub fn blinded(blinded_route_hints: Vec<(BlindedPayInfo, BlindedPath)>) -> Self { + Self { + payee: Payee::Blinded { route_hints: blinded_route_hints, features: None }, + expiry_time: None, + max_total_cltv_expiry_delta: DEFAULT_MAX_TOTAL_CLTV_EXPIRY_DELTA, + max_path_count: DEFAULT_MAX_PATH_COUNT, + max_channel_saturation_power_of_half: 2, + previously_failed_channels: Vec::new(), + } + } + + /// Includes the payee's features. Errors if the parameters were not initialized with + /// [`PaymentParameters::blinded`]. + /// + /// This is not exported to bindings users since bindings don't support move semantics + pub fn with_bolt12_features(self, features: Bolt12InvoiceFeatures) -> Result { + match self.payee { + Payee::Clear { .. } => Err(()), + Payee::Blinded { route_hints, .. } => + Ok(Self { payee: Payee::Blinded { route_hints, features: Some(features) }, ..self }) + } + } + /// Includes the payee's features. Errors if the parameters were initialized with blinded payment /// paths. /// @@ -746,6 +770,19 @@ impl Payee { _ => None, } } + fn blinded_route_hints(&self) -> &[(BlindedPayInfo, BlindedPath)] { + match self { + Self::Blinded { route_hints, .. } => &route_hints[..], + Self::Clear { .. } => &[] + } + } + + fn clear_route_hints(&self) -> &[RouteHint] { + match self { + Self::Blinded { .. } => &[], + Self::Clear { route_hints, .. } => &route_hints[..] + } + } } enum FeaturesRef<'a> { @@ -1242,10 +1279,10 @@ where L::Target: Logger { // If we're routing to a blinded recipient, we won't have their node id. Therefore, keep the // unblinded payee id as an option. We also need a non-optional "payee id" for path construction, // so use a dummy id for this in the blinded case. - let payee_node_id_opt = payment_params.payee.node_id().map(|pk| NodeId::from_pubkey(&pk)); + let mut payee_node_id_opt = payment_params.payee.node_id().map(|pk| NodeId::from_pubkey(&pk)); const DUMMY_BLINDED_PAYEE_ID: [u8; 33] = [3, 91, 229, 233, 71, 130, 9, 103, 74, 150, 230, 15, 31, 3, 127, 97, 118, 84, 15, 208, 1, 250, 29, 100, 105, 71, 112, 197, 106, 119, 9, 196, 44]; // pubkey corresponding to secret [42; 33] - let maybe_dummy_payee_pk = payment_params.payee.node_id().unwrap_or_else(|| PublicKey::from_slice(&DUMMY_BLINDED_PAYEE_ID).unwrap()); - let maybe_dummy_payee_node_id = NodeId::from_pubkey(&maybe_dummy_payee_pk); + let mut maybe_dummy_payee_pk = payment_params.payee.node_id().unwrap_or_else(|| PublicKey::from_slice(&DUMMY_BLINDED_PAYEE_ID).unwrap()); + let mut maybe_dummy_payee_node_id = NodeId::from_pubkey(&maybe_dummy_payee_pk); let our_node_id = NodeId::from_pubkey(&our_node_pubkey); if payee_node_id_opt.map_or(false, |payee| payee == our_node_id) { @@ -1270,8 +1307,26 @@ where L::Target: Logger { } } }, - _ => return Err(LightningError{err: "Routing to blinded paths isn't supported yet".to_owned(), action: ErrorAction::IgnoreError}), - + Payee::Blinded { route_hints, .. } => { + if route_hints.iter().all(|(_, path)| &path.introduction_node_id == our_node_pubkey) { + return Err(LightningError{err: "Cannot generate a route to blinded paths if we are the introduction node to all of them".to_owned(), action: ErrorAction::IgnoreError}); + } + for (_, blinded_path) in route_hints.iter() { + if blinded_path.blinded_hops.len() == 0 { + return Err(LightningError{err: "0-hop blinded path provided".to_owned(), action: ErrorAction::IgnoreError}); + } else if &blinded_path.introduction_node_id == our_node_pubkey { + log_info!(logger, "Got blinded path with ourselves as the introduction node, ignoring"); + } else if blinded_path.blinded_hops.len() == 1 { + if payee_node_id_opt.is_some() && maybe_dummy_payee_pk != blinded_path.introduction_node_id { + return Err(LightningError{err: format!("1-hop blinded paths must all have matching introduction node ids. Had node id {}, got node id {}", payee_node_id_opt.unwrap(), blinded_path.introduction_node_id), action: ErrorAction::IgnoreError}); + } + // A 1-hop blinded path indicates that the introduction node is the payee. + payee_node_id_opt = Some(NodeId::from_pubkey(&blinded_path.introduction_node_id)); + maybe_dummy_payee_pk = blinded_path.introduction_node_id; + maybe_dummy_payee_node_id = NodeId::from_pubkey(&maybe_dummy_payee_pk); + } + } + } } let final_cltv_expiry_delta = payment_params.payee.final_cltv_expiry_delta().unwrap_or(0); if payment_params.max_total_cltv_expiry_delta <= final_cltv_expiry_delta { @@ -1380,6 +1435,27 @@ where L::Target: Logger { return Err(LightningError{err: "Cannot route when there are no outbound routes away from us".to_owned(), action: ErrorAction::IgnoreError}); } } + // Marshall blinded route hints + let mut blinded_route_hints = Vec::with_capacity(payment_params.payee.blinded_route_hints().len()); + const DUMMY_BLINDED_SCID: u64 = 42; + for (idx, (blinded_payinfo, blinded_path)) in payment_params.payee.blinded_route_hints().iter().enumerate() { + if blinded_path.blinded_hops.len() == 1 || &blinded_path.introduction_node_id == our_node_pubkey { + // If the introduction node is the destination, this hint is for a public node and we can just + // use the network graph + continue + } + blinded_route_hints.push(RouteHintHop { + src_node_id: blinded_path.introduction_node_id, + short_channel_id: DUMMY_BLINDED_SCID + idx as u64, + fees: RoutingFees { + base_msat: blinded_payinfo.fee_base_msat, + proportional_millionths: blinded_payinfo.fee_proportional_millionths, + }, + cltv_expiry_delta: blinded_payinfo.cltv_expiry_delta, + htlc_minimum_msat: Some(blinded_payinfo.htlc_minimum_msat), + htlc_maximum_msat: Some(blinded_payinfo.htlc_maximum_msat), + }); + } // The main heap containing all candidate next-hops sorted by their score (max(fee, // htlc_minimum)). Ideally this would be a heap which allowed cheap score reduction instead of @@ -1794,11 +1870,20 @@ where L::Target: Logger { // If a caller provided us with last hops, add them to routing targets. Since this happens // earlier than general path finding, they will be somewhat prioritized, although currently // it matters only if the fees are exactly the same. - let route_hints = match &payment_params.payee { - Payee::Clear { route_hints, .. } => route_hints, - _ => return Err(LightningError{err: "Routing to blinded paths isn't supported yet".to_owned(), action: ErrorAction::IgnoreError}), - }; - for route in route_hints.iter().filter(|route| !route.0.is_empty()) { + for hint in blinded_route_hints.iter() { + let have_hop_src_in_graph = + // Only add the hops in this route to our candidate set if either + // we have a direct channel to the first hop or the first hop is + // in the regular network graph. + first_hop_targets.get(&NodeId::from_pubkey(&hint.src_node_id)).is_some() || + network_nodes.get(&NodeId::from_pubkey(&hint.src_node_id)).is_some(); + if have_hop_src_in_graph { + add_entry!(CandidateRouteHop::PrivateHop { hint }, + NodeId::from_pubkey(&hint.src_node_id), + maybe_dummy_payee_node_id, 0, path_value_msat, 0, 0_u64, 0, 0); + } + } + for route in payment_params.payee.clear_route_hints().iter().filter(|route| !route.0.is_empty()) { let first_hop_in_route = &(route.0)[0]; let have_hop_src_in_graph = // Only add the hops in this route to our candidate set if either @@ -2217,7 +2302,31 @@ where L::Target: Logger { for results_vec in selected_paths { let mut hops = Vec::with_capacity(results_vec.len()); for res in results_vec { hops.push(res?); } - paths.push(Path { hops, blinded_tail: None }); + let blinded_path = payment_params.payee.blinded_route_hints().iter() + .find(|(_, p)| { + let intro_node_idx = if p.blinded_hops.len() == 1 { hops.len() - 1 } + else { hops.len().saturating_sub(2) }; + p.introduction_node_id == hops[intro_node_idx].pubkey + }).map(|(_, p)| p.clone()); + let blinded_tail = if let Some(BlindedPath { blinded_hops, blinding_point, .. }) = blinded_path { + let num_blinded_hops = blinded_hops.len(); + Some(BlindedTail { + hops: blinded_hops, + blinding_point, + excess_final_cltv_expiry_delta: 0, + final_value_msat: { + if num_blinded_hops > 1 { + hops.pop().unwrap().fee_msat + } else { + let final_amt_msat = hops.last().unwrap().fee_msat; + hops.last_mut().unwrap().fee_msat = 0; + debug_assert_eq!(hops.last().unwrap().cltv_expiry_delta, 0); + final_amt_msat + } + } + }) + } else { None }; + paths.push(Path { hops, blinded_tail }); } let route = Route { paths, @@ -2407,9 +2516,10 @@ mod tests { use crate::routing::test_utils::{add_channel, add_or_update_node, build_graph, build_line_graph, id_to_feature_flags, get_nodes, update_channel}; use crate::chain::transaction::OutPoint; use crate::sign::EntropySource; - use crate::ln::features::{ChannelFeatures, InitFeatures, NodeFeatures}; + use crate::ln::features::{BlindedHopFeatures, Bolt12InvoiceFeatures, ChannelFeatures, InitFeatures, NodeFeatures}; use crate::ln::msgs::{ErrorAction, LightningError, UnsignedChannelUpdate, MAX_VALUE_MSAT}; use crate::ln::channelmanager; + use crate::offers::invoice::BlindedPayInfo; use crate::util::config::UserConfig; use crate::util::test_utils as ln_test_utils; use crate::util::chacha20::ChaCha20; @@ -4075,14 +4185,66 @@ mod tests { #[test] fn simple_mpp_route_test() { + let (secp_ctx, _, _, _, _) = build_graph(); + let (_, _, _, nodes) = get_nodes(&secp_ctx); + let config = UserConfig::default(); + let clear_payment_params = PaymentParameters::from_node_id(nodes[2], 42) + .with_bolt11_features(channelmanager::provided_invoice_features(&config)).unwrap(); + do_simple_mpp_route_test(clear_payment_params); + + // MPP to a 1-hop blinded path for nodes[2] + let bolt12_features: Bolt12InvoiceFeatures = channelmanager::provided_invoice_features(&config).to_context(); + let blinded_path = BlindedPath { + introduction_node_id: nodes[2], + blinding_point: ln_test_utils::pubkey(42), + blinded_hops: vec![BlindedHop { blinded_node_id: ln_test_utils::pubkey(42 as u8), encrypted_payload: Vec::new() }], + }; + let blinded_payinfo = BlindedPayInfo { // These fields are ignored for 1-hop blinded paths + fee_base_msat: 0, + fee_proportional_millionths: 0, + htlc_minimum_msat: 0, + htlc_maximum_msat: 0, + cltv_expiry_delta: 0, + features: BlindedHopFeatures::empty(), + }; + let one_hop_blinded_payment_params = PaymentParameters::blinded(vec![(blinded_payinfo.clone(), blinded_path.clone())]) + .with_bolt12_features(bolt12_features.clone()).unwrap(); + do_simple_mpp_route_test(one_hop_blinded_payment_params.clone()); + + // MPP to 3 2-hop blinded paths + let mut blinded_path_node_0 = blinded_path.clone(); + blinded_path_node_0.introduction_node_id = nodes[0]; + blinded_path_node_0.blinded_hops.push(blinded_path.blinded_hops[0].clone()); + let mut node_0_payinfo = blinded_payinfo.clone(); + node_0_payinfo.htlc_maximum_msat = 50_000; + + let mut blinded_path_node_7 = blinded_path_node_0.clone(); + blinded_path_node_7.introduction_node_id = nodes[7]; + let mut node_7_payinfo = blinded_payinfo.clone(); + node_7_payinfo.htlc_maximum_msat = 60_000; + + let mut blinded_path_node_1 = blinded_path_node_0.clone(); + blinded_path_node_1.introduction_node_id = nodes[1]; + let mut node_1_payinfo = blinded_payinfo.clone(); + node_1_payinfo.htlc_maximum_msat = 180_000; + + let two_hop_blinded_payment_params = PaymentParameters::blinded( + vec![ + (node_0_payinfo, blinded_path_node_0), + (node_7_payinfo, blinded_path_node_7), + (node_1_payinfo, blinded_path_node_1) + ]) + .with_bolt12_features(bolt12_features).unwrap(); + do_simple_mpp_route_test(two_hop_blinded_payment_params); + } + + + fn do_simple_mpp_route_test(payment_params: PaymentParameters) { let (secp_ctx, network_graph, gossip_sync, _, logger) = build_graph(); let (our_privkey, our_id, privkeys, nodes) = get_nodes(&secp_ctx); let scorer = ln_test_utils::TestScorer::new(); let keys_manager = ln_test_utils::TestKeysInterface::new(&[0u8; 32], Network::Testnet); let random_seed_bytes = keys_manager.get_secure_random_bytes(); - let config = UserConfig::default(); - let payment_params = PaymentParameters::from_node_id(nodes[2], 42) - .with_bolt11_features(channelmanager::provided_invoice_features(&config)).unwrap(); // We need a route consisting of 3 paths: // From our node to node2 via node0, node7, node1 (three paths one hop each). @@ -4211,8 +4373,12 @@ mod tests { assert_eq!(route.paths.len(), 3); let mut total_amount_paid_msat = 0; for path in &route.paths { - assert_eq!(path.hops.len(), 2); - assert_eq!(path.hops.last().unwrap().pubkey, nodes[2]); + if let Some(bt) = &path.blinded_tail { + assert_eq!(path.hops.len() + if bt.hops.len() == 1 { 0 } else { 1 }, 2); + } else { + assert_eq!(path.hops.len(), 2); + assert_eq!(path.hops.last().unwrap().pubkey, nodes[2]); + } total_amount_paid_msat += path.final_value_msat(); } assert_eq!(total_amount_paid_msat, 250_000); @@ -4225,8 +4391,12 @@ mod tests { assert_eq!(route.paths.len(), 3); let mut total_amount_paid_msat = 0; for path in &route.paths { - assert_eq!(path.hops.len(), 2); - assert_eq!(path.hops.last().unwrap().pubkey, nodes[2]); + if let Some(bt) = &path.blinded_tail { + assert_eq!(path.hops.len() + if bt.hops.len() == 1 { 0 } else { 1 }, 2); + } else { + assert_eq!(path.hops.len(), 2); + assert_eq!(path.hops.last().unwrap().pubkey, nodes[2]); + } total_amount_paid_msat += path.final_value_msat(); } assert_eq!(total_amount_paid_msat, 290_000); @@ -6045,6 +6215,131 @@ mod tests { assert_eq!(route.paths[0].blinded_tail.as_ref().unwrap().excess_final_cltv_expiry_delta, 40); assert_eq!(route.paths[0].hops.last().unwrap().cltv_expiry_delta, 40); } + + #[test] + fn simple_blinded_route_hints() { + do_simple_blinded_route_hints(1); + do_simple_blinded_route_hints(2); + do_simple_blinded_route_hints(3); + } + + fn do_simple_blinded_route_hints(num_blinded_hops: usize) { + // Check that we can generate a route to a blinded path with the expected hops. + let (secp_ctx, network, _, _, logger) = build_graph(); + let (_, our_id, _, nodes) = get_nodes(&secp_ctx); + let network_graph = network.read_only(); + + let scorer = ln_test_utils::TestScorer::new(); + let keys_manager = ln_test_utils::TestKeysInterface::new(&[0u8; 32], Network::Testnet); + let random_seed_bytes = keys_manager.get_secure_random_bytes(); + + let mut blinded_path = BlindedPath { + introduction_node_id: nodes[2], + blinding_point: ln_test_utils::pubkey(42), + blinded_hops: Vec::with_capacity(num_blinded_hops), + }; + for i in 0..num_blinded_hops { + blinded_path.blinded_hops.push( + BlindedHop { blinded_node_id: ln_test_utils::pubkey(42 + i as u8), encrypted_payload: vec![0; 32] }, + ); + } + let blinded_payinfo = BlindedPayInfo { + fee_base_msat: 100, + fee_proportional_millionths: 500, + htlc_minimum_msat: 1000, + htlc_maximum_msat: 100_000_000, + cltv_expiry_delta: 15, + features: BlindedHopFeatures::empty(), + }; + + let final_amt_msat = 1001; + let payment_params = PaymentParameters::blinded(vec![(blinded_payinfo.clone(), blinded_path.clone())]); + let route = get_route(&our_id, &payment_params, &network_graph, None, final_amt_msat , Arc::clone(&logger), + &scorer, &random_seed_bytes).unwrap(); + assert_eq!(route.paths.len(), 1); + assert_eq!(route.paths[0].hops.len(), 2); + + let tail = route.paths[0].blinded_tail.as_ref().unwrap(); + assert_eq!(tail.hops, blinded_path.blinded_hops); + assert_eq!(tail.excess_final_cltv_expiry_delta, 0); + assert_eq!(tail.final_value_msat, 1001); + + let final_hop = route.paths[0].hops.last().unwrap(); + assert_eq!(final_hop.pubkey, blinded_path.introduction_node_id); + if tail.hops.len() > 1 { + assert_eq!(final_hop.fee_msat, + blinded_payinfo.fee_base_msat as u64 + blinded_payinfo.fee_proportional_millionths as u64 * tail.final_value_msat / 1000000); + assert_eq!(final_hop.cltv_expiry_delta, blinded_payinfo.cltv_expiry_delta as u32); + } else { + assert_eq!(final_hop.fee_msat, 0); + assert_eq!(final_hop.cltv_expiry_delta, 0); + } + } + + #[test] + fn blinded_path_routing_errors() { + // Check that we can generate a route to a blinded path with the expected hops. + let (secp_ctx, network, _, _, logger) = build_graph(); + let (_, our_id, _, nodes) = get_nodes(&secp_ctx); + let network_graph = network.read_only(); + + let scorer = ln_test_utils::TestScorer::new(); + let keys_manager = ln_test_utils::TestKeysInterface::new(&[0u8; 32], Network::Testnet); + let random_seed_bytes = keys_manager.get_secure_random_bytes(); + + let mut invalid_blinded_path = BlindedPath { + introduction_node_id: nodes[2], + blinding_point: ln_test_utils::pubkey(42), + blinded_hops: vec![ + BlindedHop { blinded_node_id: ln_test_utils::pubkey(43), encrypted_payload: vec![0; 43] }, + ], + }; + let blinded_payinfo = BlindedPayInfo { + fee_base_msat: 100, + fee_proportional_millionths: 500, + htlc_minimum_msat: 1000, + htlc_maximum_msat: 100_000_000, + cltv_expiry_delta: 15, + features: BlindedHopFeatures::empty(), + }; + + let mut invalid_blinded_path_2 = invalid_blinded_path.clone(); + invalid_blinded_path_2.introduction_node_id = ln_test_utils::pubkey(45); + let payment_params = PaymentParameters::blinded(vec![ + (blinded_payinfo.clone(), invalid_blinded_path.clone()), + (blinded_payinfo.clone(), invalid_blinded_path_2)]); + match get_route(&our_id, &payment_params, &network_graph, None, 1001, Arc::clone(&logger), + &scorer, &random_seed_bytes) + { + Err(LightningError { err, .. }) => { + assert_eq!(err, format!("1-hop blinded paths must all have matching introduction node ids. Had node id {}, got node id {}", nodes[2], ln_test_utils::pubkey(45))); + }, + _ => panic!("Expected error") + } + + invalid_blinded_path.introduction_node_id = our_id; + let payment_params = PaymentParameters::blinded(vec![(blinded_payinfo.clone(), invalid_blinded_path.clone())]); + match get_route(&our_id, &payment_params, &network_graph, None, 1001, Arc::clone(&logger), + &scorer, &random_seed_bytes) + { + Err(LightningError { err, .. }) => { + assert_eq!(err, "Cannot generate a route to blinded paths if we are the introduction node to all of them"); + }, + _ => panic!("Expected error") + } + + invalid_blinded_path.introduction_node_id = ln_test_utils::pubkey(46); + invalid_blinded_path.blinded_hops.clear(); + let payment_params = PaymentParameters::blinded(vec![(blinded_payinfo, invalid_blinded_path)]); + match get_route(&our_id, &payment_params, &network_graph, None, 1001, Arc::clone(&logger), + &scorer, &random_seed_bytes) + { + Err(LightningError { err, .. }) => { + assert_eq!(err, "0-hop blinded path provided"); + }, + _ => panic!("Expected error") + } + } } #[cfg(all(test, not(feature = "no-std")))]