diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index b51bb460..cf35bf45 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -56,6 +56,15 @@ 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"]["node"]["Keysend"]; + assert_eq!(keysend["name"], "Keysend"); + assert_eq!(keysend["is_supported"], true); + assert_eq!(keysend["is_required"], false); + assert_eq!(keysend["is_known"], true); + assert_eq!(keysend["supported_bit"], 55); + assert_eq!(keysend["required_bit"], 54); } #[tokio::test] @@ -226,12 +235,14 @@ async fn test_cli_decode_invoice() { feature_names ); - // Every entry should have the expected structure - for (bit, feature) in features { - assert!(bit.parse::().is_ok(), "Feature key should be a bit number: {}", bit); + // Every entry should have the expected structure. + for (name, feature) in features { + assert_eq!(feature["name"], *name); assert!(feature.get("name").is_some(), "Feature missing name field"); + assert!(feature.get("is_supported").is_some(), "Feature missing is_supported 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"); + assert!(feature.get("supported_bit").is_some(), "Feature missing supported_bit field"); } // Also test a variable-amount invoice diff --git a/ldk-server-grpc/src/api.rs b/ldk-server-grpc/src/api.rs index b4eda4ed..38d602b7 100644 --- a/ldk-server-grpc/src/api.rs +++ b/ldk-server-grpc/src/api.rs @@ -85,6 +85,10 @@ 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, + /// The feature sets advertised by this node. Currently only node-announcement + /// features are populated. + #[prost(message, optional, tag = "14")] + pub features: ::core::option::Option, } /// Retrieve a new on-chain funding address. /// See more: @@ -1164,9 +1168,10 @@ 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 feature name. + #[prost(map = "string, message", tag = "11")] + pub features: + ::std::collections::HashMap<::prost::alloc::string::String, super::types::Feature>, /// The currency or network (e.g., "bitcoin", "testnet", "signet", "regtest"). #[prost(string, tag = "12")] pub currency: ::prost::alloc::string::String, @@ -1220,9 +1225,10 @@ 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 feature name. + #[prost(map = "string, message", tag = "9")] + pub features: + ::std::collections::HashMap<::prost::alloc::string::String, super::types::Feature>, /// 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..e2be4908 100644 --- a/ldk-server-grpc/src/proto/api.proto +++ b/ldk-server-grpc/src/proto/api.proto @@ -73,6 +73,10 @@ message GetNodeInfoResponse { // The Bitcoin network the node is running on (e.g., "bitcoin", "testnet", "signet", "regtest"). types.Network network = 13; + + // The feature sets advertised by this node. Currently only node-announcement + // features are populated. + types.Features features = 14; } // Retrieve a new on-chain funding address. @@ -840,8 +844,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 feature name. + map features = 11; // The currency or network (e.g., "bitcoin", "testnet", "signet", "regtest"). string currency = 12; @@ -886,8 +890,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 feature name. + 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..ab0a16d4 100644 --- a/ldk-server-grpc/src/proto/types.proto +++ b/ldk-server-grpc/src/proto/types.proto @@ -924,16 +924,27 @@ 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. - bool is_required = 2; + // Whether this feature's support bit is set. + bool is_supported = 2; - // Whether this feature is known. - bool is_known = 3; + // Whether this feature's required bit is set. + bool is_required = 3; + + // Whether this feature is known by LDK. + bool is_known = 4; + + // The BOLT 9 bit that indicates support for this feature. + uint32 supported_bit = 5; + + // The BOLT 9 bit that requires support for this feature, if one exists. + // Optional because some feature contexts include support-only features without + // a corresponding required bit, e.g. `initial_routing_sync` in init features. + optional uint32 required_bit = 6; } // Custom TLV record attached to a payment. @@ -943,3 +954,10 @@ message CustomTlvRecord { // Raw TLV value. bytes value = 2; } + +// The feature sets advertised by this node. Currently only node-announcement +// features are populated. +message Features { + // Node-announcement features keyed by feature name. + map node = 1; +} diff --git a/ldk-server-grpc/src/types.rs b/ldk-server-grpc/src/types.rs index 54e47f87..85fd62bb 100644 --- a/ldk-server-grpc/src/types.rs +++ b/ldk-server-grpc/src/types.rs @@ -1212,22 +1212,33 @@ 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 this feature's support bit is set. #[prost(bool, tag = "2")] - pub is_required: bool, - /// Whether this feature is known. + pub is_supported: bool, + /// Whether this feature's required bit is set. #[prost(bool, tag = "3")] + pub is_required: bool, + /// Whether this feature is known by LDK. + #[prost(bool, tag = "4")] pub is_known: bool, + /// The BOLT 9 bit that indicates support for this feature. + #[prost(uint32, tag = "5")] + pub supported_bit: u32, + /// The BOLT 9 bit that requires support for this feature, if one exists. + /// Optional because some feature contexts include support-only features without + /// a corresponding required bit, e.g. `initial_routing_sync` in init features. + #[prost(uint32, optional, tag = "6")] + pub required_bit: ::core::option::Option, } /// Custom TLV record attached to a payment. #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] @@ -1243,6 +1254,18 @@ pub struct CustomTlvRecord { #[prost(bytes = "bytes", tag = "2")] pub value: ::prost::bytes::Bytes, } +/// The feature sets advertised by this node. Currently only node-announcement +/// features are populated. +#[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 Features { + /// Node-announcement features keyed by feature name. + #[prost(map = "string, message", tag = "1")] + pub node: ::std::collections::HashMap<::prost::alloc::string::String, Feature>, +} /// Represents the direction of a payment. #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 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..e76b3236 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 ldk_server_grpc::types::{BestBlock, Features}; 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,12 @@ pub(crate) async fn handle_get_node_info_request( height: node_status.current_best_block.height, }; + let features = Features { + node: 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 +73,7 @@ pub(crate) async fn handle_get_node_info_request( node_alias, node_uris, network, + features: Some(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..475b0977 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::HashMap; + 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,84 @@ pub(crate) fn graph_node_to_proto(node: NodeInfo) -> ldk_server_grpc::types::Gra } } +/// Converts LDK feature flags into proto features keyed by feature name. +/// +/// Feature names are derived from LDK's `Features::Display` impl, so they stay +/// in sync automatically. If both the required and supported bits are set for +/// the same feature, they are merged into a single proto `Feature`. +pub(crate) fn features_to_proto( + 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; + let required_bit = if is_required { + Some(bit_number) + } else if bit_number == 3 { + // `initial_routing_sync` uses bit 3 and has no required/even bit per BOLT 9. + None + } else { + // BOLT 9 feature pairs use an even required bit followed by an odd support bit. + Some(bit_number - 1) + }; + let supported_bit = if is_required { bit_number + 1 } else { bit_number }; + + // 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 (parsed_name, is_known) = parse_feature_name(&display); + let name = if is_known { + parsed_name.to_string() + } else { + let required = required_bit + .map(|bit| bit.to_string()) + .unwrap_or_else(|| "none".to_string()); + format!("UnknownFeature{required}_{supported_bit}") + }; + + let feature = features.entry(name.clone()).or_insert_with(|| Feature { + name, + is_supported: false, + is_required: false, + is_known, + supported_bit, + required_bit, + }); + feature.is_supported = true; + feature.is_required |= is_required; + } + } + } + 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 {