From 91c483c2b0eea211a56b394a240453fadb96754a Mon Sep 17 00:00:00 2001 From: Enigbe Date: Fri, 26 Jun 2026 15:35:31 +0100 Subject: [PATCH] Expose graph node announcement features Include the feature map from LDK's NodeAnnouncementInfo in GraphNodeAnnouncement. Features are part of the BOLT 7 node_announcement payload, so exposing them makes the graph node announcement proto match the underlying gossip data more completely. Reuse the existing bit-keyed Feature representation and conversion helper, and cover the new field in the graph-get-node e2e test. This also supports callers such as sim-ln that need to inspect other nodes' advertised capabilities. For context: https://github.com/bitcoin-dev-project/sim-ln/pull/307 https://github.com/bitcoin-dev-project/sim-ln/pull/307/changes#r3481510079 --- e2e-tests/tests/e2e.rs | 40 +++++++++++++++++++++++++-- ldk-server-grpc/build.rs | 1 + ldk-server-grpc/src/proto/types.proto | 3 ++ ldk-server-grpc/src/types.rs | 3 ++ ldk-server/src/util/proto_adapter.rs | 10 +++++-- 5 files changed, 53 insertions(+), 4 deletions(-) diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index a5314ecb..423838f1 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -1111,11 +1111,47 @@ async fn test_cli_graph_with_channel() { assert!(node_ids.contains(&server_a.node_id()), "Expected server_a in graph nodes"); assert!(node_ids.contains(&server_b.node_id()), "Expected server_b in graph nodes"); - // Test GraphGetNode: should return node info with at least one channel. - let output = run_cli(&server_a, &["graph-get-node", server_b.node_id()]); + // Test GraphGetNode: should return node info with at least one channel and + // node announcement features once the node announcement reaches the graph. + let output = { + let start = std::time::Instant::now(); + loop { + let output = run_cli(&server_a, &["graph-get-node", server_b.node_id()]); + let node = &output["node"]; + let has_channel = + node["channels"].as_array().is_some_and(|channels| !channels.is_empty()); + let has_announcement_features = node["announcement_info"]["features"] + .as_object() + .is_some_and(|features| !features.is_empty()); + + if has_channel && has_announcement_features { + break output; + } + if start.elapsed() > Duration::from_secs(30) { + panic!("Timed out waiting for node announcement features in network graph"); + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + }; let node = &output["node"]; let channels = node["channels"].as_array().unwrap(); assert!(!channels.is_empty(), "Expected node to have at least one channel"); + + let announcement_info = &node["announcement_info"]; + let features = announcement_info["features"].as_object().unwrap(); + assert!(!features.is_empty(), "Expected node announcement features"); + + // 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 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"); + } + + let keysend = &features["55"]; + assert_eq!(keysend["name"], "Keysend"); + assert_eq!(keysend["is_required"], false); } #[tokio::test] diff --git a/ldk-server-grpc/build.rs b/ldk-server-grpc/build.rs index b0c5c4a9..2f8b4392 100644 --- a/ldk-server-grpc/build.rs +++ b/ldk-server-grpc/build.rs @@ -40,6 +40,7 @@ fn generate_protos() { "api.GetNodeInfoResponse.features", "api.DecodeInvoiceResponse.features", "api.DecodeOfferResponse.features", + "types.GraphNodeAnnouncement.features", ]) .type_attribute( ".", diff --git a/ldk-server-grpc/src/proto/types.proto b/ldk-server-grpc/src/proto/types.proto index 8179abb2..654a3a80 100644 --- a/ldk-server-grpc/src/proto/types.proto +++ b/ldk-server-grpc/src/proto/types.proto @@ -798,6 +798,9 @@ message GraphNodeAnnouncement { // List of addresses on which this node is reachable. repeated string addresses = 4; + + // Features signaled in this node announcement, keyed by feature bit. + map features = 5; } // Details of a known Lightning peer. diff --git a/ldk-server-grpc/src/types.rs b/ldk-server-grpc/src/types.rs index c2d1b0f6..1240cb46 100644 --- a/ldk-server-grpc/src/types.rs +++ b/ldk-server-grpc/src/types.rs @@ -1019,6 +1019,9 @@ pub struct GraphNodeAnnouncement { /// List of addresses on which this node is reachable. #[prost(string, repeated, tag = "4")] pub addresses: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + /// Features signaled in this node announcement, keyed by feature bit. + #[prost(btree_map = "uint32, message", tag = "5")] + pub features: ::prost::alloc::collections::BTreeMap, } /// Details of a known Lightning peer. /// See more: diff --git a/ldk-server/src/util/proto_adapter.rs b/ldk-server/src/util/proto_adapter.rs index 4ae1b2f8..7c94453d 100644 --- a/ldk-server/src/util/proto_adapter.rs +++ b/ldk-server/src/util/proto_adapter.rs @@ -7,10 +7,10 @@ // You may not use this file except in accordance with one or both of these // licenses. -use bytes::Bytes; -use hex::prelude::*; use std::collections::BTreeMap; +use bytes::Bytes; +use hex::prelude::*; use ldk_node::bitcoin::hashes::sha256; use ldk_node::bitcoin::Network; use ldk_node::config::{ChannelConfig, MaxDustHTLCExposure}; @@ -19,6 +19,7 @@ use ldk_node::lightning::routing::gossip::{ ChannelInfo, ChannelUpdateInfo, NodeAnnouncementInfo, NodeInfo, RoutingFees, }; use ldk_node::lightning_invoice::{Bolt11InvoiceDescription, Description, Sha256}; +use ldk_node::lightning_types::features::NodeFeatures; use ldk_node::payment::{ ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, }; @@ -472,11 +473,16 @@ pub(crate) fn graph_node_announcement_to_proto( announcement: NodeAnnouncementInfo, ) -> ldk_server_grpc::types::GraphNodeAnnouncement { let rgb = announcement.rgb(); + let features = features_to_proto(announcement.features().le_flags(), |bytes| { + NodeFeatures::from_le_bytes(bytes).to_string() + }); + ldk_server_grpc::types::GraphNodeAnnouncement { last_update: announcement.last_update(), alias: announcement.alias().to_string(), rgb: format!("{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]), addresses: announcement.addresses().iter().map(|a| a.to_string()).collect(), + features, } }