From 45c65160379f0ec8d6ec9a82bb24a85d0cf2d958 Mon Sep 17 00:00:00 2001 From: Enigbe Date: Thu, 26 Feb 2026 17:47:03 +0100 Subject: [PATCH] Expose node features in get-node-info Return the node-announcement feature set from GetNodeInfoResponse so clients can inspect advertised node capabilities, such as keysend support, directly from the node info API. Decode exposed feature bytes into semantic entries keyed by the signaled BOLT feature bit. Each entry carries the decoded name and whether that bit is required, while presence in the map indicates the bit is signaled. --- e2e-tests/tests/e2e.rs | 54 ++++++++++----------- ldk-server-grpc/build.rs | 5 ++ ldk-server-grpc/src/api.rs | 15 +++--- ldk-server-grpc/src/proto/api.proto | 11 +++-- ldk-server-grpc/src/proto/types.proto | 9 ++-- ldk-server-grpc/src/types.rs | 9 ++-- ldk-server/src/api/decode_invoice.rs | 4 +- ldk-server/src/api/decode_offer.rs | 4 +- ldk-server/src/api/get_node_info.rs | 8 +++- ldk-server/src/api/mod.rs | 58 +---------------------- ldk-server/src/util/proto_adapter.rs | 67 ++++++++++++++++++++++++++- 11 files changed, 129 insertions(+), 115 deletions(-) diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index b51bb460..6c094a8c 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -56,6 +56,11 @@ async fn test_cli_get_node_info() { let output = run_cli(&server, &["get-node-info"]); assert!(output.get("node_id").is_some()); assert_eq!(output["node_id"], server.node_id()); + + // Ensure clients can inspect advertised node capabilities from get-node-info. + let keysend = &output["features"]["55"]; + assert_eq!(keysend["name"], "Keysend"); + assert_eq!(keysend["is_required"], false); } #[tokio::test] @@ -207,33 +212,27 @@ async fn test_cli_decode_invoice() { // Verify features — LDK BOLT11 invoices always set VariableLengthOnion, PaymentSecret, // and BasicMPP. let features = decoded["features"].as_object().unwrap(); - assert!(!features.is_empty(), "Expected at least one feature"); - - let feature_names: Vec<&str> = features.values().filter_map(|f| f["name"].as_str()).collect(); - assert!( - feature_names.contains(&"VariableLengthOnion"), - "Expected VariableLengthOnion in features: {:?}", - feature_names - ); - assert!( - feature_names.contains(&"PaymentSecret"), - "Expected PaymentSecret in features: {:?}", - feature_names - ); - assert!( - feature_names.contains(&"BasicMPP"), - "Expected BasicMPP in features: {:?}", - feature_names - ); - // Every entry should have the expected structure + // Every entry should be keyed by the signaled bit and expose the decoded name + // plus whether that bit is required. for (bit, feature) in features { - assert!(bit.parse::().is_ok(), "Feature key should be a bit number: {}", bit); + assert!(bit.parse::().is_ok(), "Feature key is not a bit number: {bit}"); assert!(feature.get("name").is_some(), "Feature missing name field"); assert!(feature.get("is_required").is_some(), "Feature missing is_required field"); - assert!(feature.get("is_known").is_some(), "Feature missing is_known field"); } + let variable_length_onion = &features["8"]; + assert_eq!(variable_length_onion["name"], "VariableLengthOnion"); + assert_eq!(variable_length_onion["is_required"], true); + + let payment_secret = &features["14"]; + assert_eq!(payment_secret["name"], "PaymentSecret"); + assert_eq!(payment_secret["is_required"], true); + + let basic_mpp = &features["17"]; + assert_eq!(basic_mpp["name"], "BasicMPP"); + assert_eq!(basic_mpp["is_required"], false); + // Also test a variable-amount invoice let output_var = run_cli(&server, &["bolt11-receive", "-d", "no amount"]); let decoded_var = @@ -927,17 +926,13 @@ async fn test_cli_spontaneous_send_with_custom_tlvs() { assert!(!output["payment_id"].as_str().unwrap().is_empty()); // The receiver must observe both TLVs in PaymentReceived. - let event_b = - wait_for_event(&mut events_b, |e| matches!(e, Event::PaymentReceived(_))).await; + let event_b = wait_for_event(&mut events_b, |e| matches!(e, Event::PaymentReceived(_))).await; let Some(Event::PaymentReceived(pr)) = event_b.event else { panic!("expected PaymentReceived"); }; assert_eq!(pr.custom_records.len(), 2); - let by_type: HashMap> = pr - .custom_records - .into_iter() - .map(|r| (r.type_num, r.value.to_vec())) - .collect(); + let by_type: HashMap> = + pr.custom_records.into_iter().map(|r| (r.type_num, r.value.to_vec())).collect(); assert_eq!(by_type.get(&65537).cloned(), Some(vec![0xde, 0xad, 0xbe, 0xef])); assert_eq!(by_type.get(&65539).cloned(), Some(vec![0xca, 0xfe])); } @@ -1177,8 +1172,7 @@ async fn test_forwarded_payment_event() { builder_c.set_liquidity_source_lsps2(b_node_id, b_addr, None); let mnemonic_c = ldk_node::entropy::generate_entropy_mnemonic(None); - let node_entropy_c = - ldk_node::entropy::NodeEntropy::from_bip39_mnemonic(mnemonic_c, None); + let node_entropy_c = ldk_node::entropy::NodeEntropy::from_bip39_mnemonic(mnemonic_c, None); let node_c = builder_c.build(node_entropy_c).unwrap(); node_c.start().unwrap(); diff --git a/ldk-server-grpc/build.rs b/ldk-server-grpc/build.rs index b5171a0c..b0c5c4a9 100644 --- a/ldk-server-grpc/build.rs +++ b/ldk-server-grpc/build.rs @@ -36,6 +36,11 @@ fn main() { fn generate_protos() { prost_build::Config::new() .bytes(&["."]) + .btree_map(&[ + "api.GetNodeInfoResponse.features", + "api.DecodeInvoiceResponse.features", + "api.DecodeOfferResponse.features", + ]) .type_attribute( ".", "#[cfg_attr(feature = \"serde\", derive(serde::Serialize, serde::Deserialize))]", diff --git a/ldk-server-grpc/src/api.rs b/ldk-server-grpc/src/api.rs index b4eda4ed..26016bf4 100644 --- a/ldk-server-grpc/src/api.rs +++ b/ldk-server-grpc/src/api.rs @@ -85,6 +85,9 @@ pub struct GetNodeInfoResponse { #[prost(enumeration = "super::types::Network", tag = "13")] #[cfg_attr(feature = "serde", serde(serialize_with = "crate::serde_utils::serialize_network"))] pub network: i32, + /// Features advertised by this node, keyed by the signaled BOLT feature bit. + #[prost(btree_map = "uint32, message", tag = "14")] + pub features: ::prost::alloc::collections::BTreeMap, } /// Retrieve a new on-chain funding address. /// See more: @@ -1164,9 +1167,9 @@ pub struct DecodeInvoiceResponse { /// Route hints for finding a path to the payee. #[prost(message, repeated, tag = "10")] pub route_hints: ::prost::alloc::vec::Vec, - /// Feature bits advertised in the invoice, keyed by bit number. - #[prost(map = "uint32, message", tag = "11")] - pub features: ::std::collections::HashMap, + /// Features advertised in the invoice, keyed by the signaled BOLT feature bit. + #[prost(btree_map = "uint32, message", tag = "11")] + pub features: ::prost::alloc::collections::BTreeMap, /// The currency or network (e.g., "bitcoin", "testnet", "signet", "regtest"). #[prost(string, tag = "12")] pub currency: ::prost::alloc::string::String, @@ -1220,9 +1223,9 @@ pub struct DecodeOfferResponse { /// Blinded paths to the offer recipient. #[prost(message, repeated, tag = "8")] pub paths: ::prost::alloc::vec::Vec, - /// Feature bits advertised in the offer, keyed by bit number. - #[prost(map = "uint32, message", tag = "9")] - pub features: ::std::collections::HashMap, + /// Features advertised in the offer, keyed by the signaled BOLT feature bit. + #[prost(btree_map = "uint32, message", tag = "9")] + pub features: ::prost::alloc::collections::BTreeMap, /// Supported blockchain networks (e.g., "bitcoin", "testnet", "signet", "regtest"). #[prost(string, repeated, tag = "10")] pub chains: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, diff --git a/ldk-server-grpc/src/proto/api.proto b/ldk-server-grpc/src/proto/api.proto index 516c406b..244af5d9 100644 --- a/ldk-server-grpc/src/proto/api.proto +++ b/ldk-server-grpc/src/proto/api.proto @@ -73,6 +73,9 @@ message GetNodeInfoResponse { // The Bitcoin network the node is running on (e.g., "bitcoin", "testnet", "signet", "regtest"). types.Network network = 13; + + // Features advertised by this node, keyed by the signaled BOLT feature bit. + map features = 14; } // Retrieve a new on-chain funding address. @@ -840,8 +843,8 @@ message DecodeInvoiceResponse { // Route hints for finding a path to the payee. repeated types.Bolt11RouteHint route_hints = 10; - // Feature bits advertised in the invoice, keyed by bit number. - map features = 11; + // Features advertised in the invoice, keyed by the signaled BOLT feature bit. + map features = 11; // The currency or network (e.g., "bitcoin", "testnet", "signet", "regtest"). string currency = 12; @@ -886,8 +889,8 @@ message DecodeOfferResponse { // Blinded paths to the offer recipient. repeated types.BlindedPath paths = 8; - // Feature bits advertised in the offer, keyed by bit number. - map features = 9; + // Features advertised in the offer, keyed by the signaled BOLT feature bit. + map features = 9; // Supported blockchain networks (e.g., "bitcoin", "testnet", "signet", "regtest"). repeated string chains = 10; diff --git a/ldk-server-grpc/src/proto/types.proto b/ldk-server-grpc/src/proto/types.proto index efe152d6..8179abb2 100644 --- a/ldk-server-grpc/src/proto/types.proto +++ b/ldk-server-grpc/src/proto/types.proto @@ -924,16 +924,13 @@ enum ChannelDirection { NODE_TWO = 1; } -// A feature bit advertised in a BOLT11 invoice. -message Bolt11Feature { +// A feature advertised in a BOLT feature context. +message Feature { // Human-readable feature name. string name = 1; - // Whether this feature is required. + // Whether the signaled feature bit is required. bool is_required = 2; - - // Whether this feature is known. - bool is_known = 3; } // Custom TLV record attached to a payment. diff --git a/ldk-server-grpc/src/types.rs b/ldk-server-grpc/src/types.rs index 54e47f87..c2d1b0f6 100644 --- a/ldk-server-grpc/src/types.rs +++ b/ldk-server-grpc/src/types.rs @@ -1212,22 +1212,19 @@ pub struct DirectedShortChannelId { )] pub direction: i32, } -/// A feature bit advertised in a BOLT11 invoice. +/// A feature advertised in a BOLT feature context. #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[cfg_attr(feature = "serde", serde(default))] #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] -pub struct Bolt11Feature { +pub struct Feature { /// Human-readable feature name. #[prost(string, tag = "1")] pub name: ::prost::alloc::string::String, - /// Whether this feature is required. + /// Whether the signaled feature bit is required. #[prost(bool, tag = "2")] pub is_required: bool, - /// Whether this feature is known. - #[prost(bool, tag = "3")] - pub is_known: bool, } /// Custom TLV record attached to a payment. #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] diff --git a/ldk-server/src/api/decode_invoice.rs b/ldk-server/src/api/decode_invoice.rs index e12b9de1..ef7479e2 100644 --- a/ldk-server/src/api/decode_invoice.rs +++ b/ldk-server/src/api/decode_invoice.rs @@ -16,9 +16,9 @@ use ldk_node::lightning_types::features::Bolt11InvoiceFeatures; use ldk_server_grpc::api::{DecodeInvoiceRequest, DecodeInvoiceResponse}; use ldk_server_grpc::types::{Bolt11HopHint, Bolt11RouteHint}; -use crate::api::decode_features; use crate::api::error::LdkServerError; use crate::service::Context; +use crate::util::proto_adapter::features_to_proto; pub(crate) async fn handle_decode_invoice_request( _context: Arc, request: DecodeInvoiceRequest, @@ -66,7 +66,7 @@ pub(crate) async fn handle_decode_invoice_request( let features = invoice .features() .map(|f| { - decode_features(f.le_flags(), |bytes| { + features_to_proto(f.le_flags(), |bytes| { Bolt11InvoiceFeatures::from_le_bytes(bytes).to_string() }) }) diff --git a/ldk-server/src/api/decode_offer.rs b/ldk-server/src/api/decode_offer.rs index 7669ba5d..c5227859 100644 --- a/ldk-server/src/api/decode_offer.rs +++ b/ldk-server/src/api/decode_offer.rs @@ -24,9 +24,9 @@ use ldk_server_grpc::types::{ OfferQuantity, }; -use crate::api::decode_features; use crate::api::error::LdkServerError; use crate::service::Context; +use crate::util::proto_adapter::features_to_proto; pub(crate) async fn handle_decode_offer_request( _context: Arc, request: DecodeOfferRequest, @@ -104,7 +104,7 @@ pub(crate) async fn handle_decode_offer_request( }) .collect(); - let features = decode_features(offer.offer_features().le_flags(), |bytes| { + let features = features_to_proto(offer.offer_features().le_flags(), |bytes| { OfferFeatures::from_le_bytes(bytes).to_string() }); diff --git a/ldk-server/src/api/get_node_info.rs b/ldk-server/src/api/get_node_info.rs index 1b5aa109..154cdb3f 100644 --- a/ldk-server/src/api/get_node_info.rs +++ b/ldk-server/src/api/get_node_info.rs @@ -9,12 +9,13 @@ use std::sync::Arc; +use ldk_node::lightning_types::features::NodeFeatures; use ldk_server_grpc::api::{GetNodeInfoRequest, GetNodeInfoResponse}; use ldk_server_grpc::types::BestBlock; use crate::api::error::LdkServerError; use crate::service::Context; -use crate::util::proto_adapter::network_to_proto; +use crate::util::proto_adapter::{features_to_proto, network_to_proto}; pub(crate) async fn handle_get_node_info_request( context: Arc, _request: GetNodeInfoRequest, @@ -26,6 +27,10 @@ pub(crate) async fn handle_get_node_info_request( height: node_status.current_best_block.height, }; + let features = features_to_proto(node_status.node_features.le_flags(), |bytes| { + NodeFeatures::from_le_bytes(bytes).to_string() + }); + let listening_addresses: Vec = context .node .listening_addresses() @@ -66,6 +71,7 @@ pub(crate) async fn handle_get_node_info_request( node_alias, node_uris, network, + features, }; Ok(response) } diff --git a/ldk-server/src/api/mod.rs b/ldk-server/src/api/mod.rs index 7163c53a..2947257d 100644 --- a/ldk-server/src/api/mod.rs +++ b/ldk-server/src/api/mod.rs @@ -7,13 +7,11 @@ // You may not use this file except in accordance with one or both of these // licenses. -use std::collections::HashMap; - use ldk_node::config::{ChannelConfig, MaxDustHTLCExposure}; use ldk_node::lightning::routing::router::RouteParametersConfig; use ldk_node::CustomTlvRecord as NodeCustomTlvRecord; use ldk_server_grpc::types::channel_config::MaxDustHtlcExposure; -use ldk_server_grpc::types::{Bolt11Feature, CustomTlvRecord as ProtoCustomTlvRecord}; +use ldk_server_grpc::types::CustomTlvRecord as ProtoCustomTlvRecord; use crate::api::error::LdkServerError; use crate::api::error::LdkServerErrorCode::InvalidRequestError; @@ -138,60 +136,6 @@ pub(crate) fn node_to_proto_custom_tlv(node: &NodeCustomTlvRecord) -> ProtoCusto ProtoCustomTlvRecord { type_num: node.type_num, value: node.value.clone().into() } } -/// Decodes feature flags into a map keyed by bit number. Feature names are derived -/// from LDK's `Features::Display` impl, so they stay in sync automatically. -/// -/// `make_display` should construct a `Features` from the given LE bytes and return -/// its `to_string()` output — this lets us probe LDK for the name of each set bit. -pub(crate) fn decode_features( - le_flags: &[u8], make_display: impl Fn(Vec) -> String, -) -> HashMap { - let mut features = HashMap::new(); - for (byte_idx, &byte) in le_flags.iter().enumerate() { - if byte == 0 { - continue; - } - for bit_pos in 0..8u32 { - if byte & (1 << bit_pos) != 0 { - let bit_number = (byte_idx as u32) * 8 + bit_pos; - let is_required = bit_number % 2 == 0; - - // Create Features with just this bit set and use Display to get the name. - let mut single_bit = vec![0u8; byte_idx + 1]; - single_bit[byte_idx] = 1 << bit_pos; - let display = make_display(single_bit); - let (name, is_known) = parse_feature_name(&display); - - features.insert( - bit_number, - Bolt11Feature { name: name.to_string(), is_required, is_known }, - ); - } - } - } - features -} - -/// Parse the Display output of a single-bit Features to find which feature is set. -/// -/// LDK's Display format is: "Name: status, Name: status, ..., unknown flags: status" -/// where status is "required", "supported", or "not supported". -/// For a single-bit Features, exactly one entry will be "required" or "supported". -fn parse_feature_name(display: &str) -> (&str, bool) { - for entry in display.split(", ") { - if let Some((name, status)) = entry.split_once(": ") { - if name == "unknown flags" { - if status == "required" || status == "supported" { - return ("unknown", false); - } - } else if status == "required" || status == "supported" { - return (name, true); - } - } - } - ("unknown", false) -} - #[cfg(test)] mod tests { use super::*; diff --git a/ldk-server/src/util/proto_adapter.rs b/ldk-server/src/util/proto_adapter.rs index ae75ebe5..4ae1b2f8 100644 --- a/ldk-server/src/util/proto_adapter.rs +++ b/ldk-server/src/util/proto_adapter.rs @@ -9,6 +9,8 @@ use bytes::Bytes; use hex::prelude::*; +use std::collections::BTreeMap; + use ldk_node::bitcoin::hashes::sha256; use ldk_node::bitcoin::Network; use ldk_node::config::{ChannelConfig, MaxDustHTLCExposure}; @@ -33,7 +35,8 @@ use ldk_server_grpc::types::pending_sweep_balance::BalanceType::{ AwaitingThresholdConfirmations, BroadcastAwaitingConfirmation, PendingBroadcast, }; use ldk_server_grpc::types::{ - bolt11_invoice_description, Channel, ForwardedPayment, HtlcLocator, OutPoint, Payment, Peer, + bolt11_invoice_description, Channel, Feature, ForwardedPayment, HtlcLocator, OutPoint, Payment, + Peer, }; use crate::api::error::LdkServerError; @@ -484,6 +487,68 @@ pub(crate) fn graph_node_to_proto(node: NodeInfo) -> ldk_server_grpc::types::Gra } } +/// Converts LDK feature flags into proto features keyed by the signaled bit. +/// +/// Feature names are derived from LDK's `Features::Display` impl, so they stay +/// in sync automatically. Unknown feature bits are skipped because returned +/// feature entries represent decoded features known by LDK. +pub(crate) fn features_to_proto( + le_flags: &[u8], make_display: impl Fn(Vec) -> String, +) -> BTreeMap { + let mut features = BTreeMap::new(); + + for (byte_idx, &byte) in le_flags.iter().enumerate() { + if byte == 0 { + continue; + } + + for bit_pos in 0..8u32 { + if byte & (1 << bit_pos) == 0 { + continue; + } + + let bit_number = (byte_idx as u32) * 8 + bit_pos; + + // Create Features with just this bit set and use Display to get the name. + let mut single_bit = vec![0u8; byte_idx + 1]; + single_bit[byte_idx] = 1 << bit_pos; + + let display = make_display(single_bit); + let (name, is_known) = parse_feature_name(&display); + if !is_known { + continue; + } + + features.insert( + bit_number, + Feature { name: name.to_string(), is_required: bit_number % 2 == 0 }, + ); + } + } + + features +} + +/// Parse the Display output of a single-bit Features to find which feature is set. +/// +/// LDK's Display format is: "Name: status, Name: status, ..., unknown flags: status" +/// where status is "required", "supported", or "not supported". +/// For a single-bit Features, exactly one entry will be "required" or "supported". +fn parse_feature_name(display: &str) -> (&str, bool) { + for entry in display.split(", ") { + if let Some((name, status)) = entry.split_once(": ") { + if name == "unknown flags" { + if status == "required" || status == "supported" { + return ("unknown", false); + } + } else if status == "required" || status == "supported" { + return (name, true); + } + } + } + ("unknown", false) +} + pub(crate) fn network_to_proto(network: Network) -> ldk_server_grpc::types::Network { use ldk_server_grpc::types::Network as ProtoNetwork; match network {