From c063fcee113b20674ba0a0612159abdad8784260 Mon Sep 17 00:00:00 2001 From: jaoleal Date: Fri, 29 May 2026 17:19:33 -0300 Subject: [PATCH 1/4] feat(wire): add GetAddedNodeInfo request and response plumbing Add the wire-layer plumbing for the getaddednodeinfo RPC: - AddedNodeInfo struct in node_interface - UserRequest::GetAddedNodeInfo and NodeResponse::GetAddedNodeInfo variants - get_added_node_info() method on NodeInterface - handle_get_added_node_info() implementation in peer_man - Match arm in perform_user_request() --- .../src/p2p_wire/node/peer_man.rs | 48 +++++++++++++++++++ .../src/p2p_wire/node/user_req.rs | 6 +++ .../src/p2p_wire/node_interface.rs | 42 ++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/crates/floresta-wire/src/p2p_wire/node/peer_man.rs b/crates/floresta-wire/src/p2p_wire/node/peer_man.rs index 88c0c7dfd..983004c70 100644 --- a/crates/floresta-wire/src/p2p_wire/node/peer_man.rs +++ b/crates/floresta-wire/src/p2p_wire/node/peer_man.rs @@ -34,6 +34,8 @@ use crate::block_proof::Bitmap; use crate::node::running_ctx::RunningNode; use crate::node_context::NodeContext; use crate::node_context::PeerId; +use crate::node_interface::AddedNodeAddress; +use crate::node_interface::AddedNodeInfo; use crate::node_interface::NodeResponse; use crate::node_interface::PeerInfo; use crate::node_interface::UserRequest; @@ -830,6 +832,52 @@ where }) } + pub(crate) fn handle_get_added_node_info( + &self, + node: Option<(IpAddr, u16)>, + ) -> Vec { + self.added_peers + .iter() + .filter_map(|added| { + let added_addr = match &added.address { + AddrV2::Ipv4(ip) => IpAddr::V4(*ip), + AddrV2::Ipv6(ip) => IpAddr::V6(*ip), + _ => IpAddr::V4(core::net::Ipv4Addr::UNSPECIFIED), + }; + + // If a node filter is specified, skip entries that don't match + if let Some((filter_ip, filter_port)) = &node { + if added_addr != *filter_ip || added.port != *filter_port { + return None; + } + } + + let connected = self.peers.values().any(|peer| { + peer.address == added_addr + && peer.port == added.port + && peer.state == PeerStatus::Ready + }); + + let addr_str = format!("{added_addr}:{}", added.port); + + let addresses = if connected { + vec![AddedNodeAddress { + address: addr_str.clone(), + connected: "outbound".to_string(), + }] + } else { + vec![] + }; + + Some(AddedNodeInfo { + addednode: addr_str, + connected, + addresses, + }) + }) + .collect() + } + // === ADDNODE === // TODO: remove this after bitcoin-0.33.0 diff --git a/crates/floresta-wire/src/p2p_wire/node/user_req.rs b/crates/floresta-wire/src/p2p_wire/node/user_req.rs index a286896d1..42171af06 100644 --- a/crates/floresta-wire/src/p2p_wire/node/user_req.rs +++ b/crates/floresta-wire/src/p2p_wire/node/user_req.rs @@ -166,6 +166,12 @@ where return; } + UserRequest::GetAddedNodeInfo(node) => { + let info = self.handle_get_added_node_info(node); + try_and_log!(responder.send(NodeResponse::GetAddedNodeInfo(info))); + return; + } + UserRequest::SendTransaction(transaction) => { let txid = transaction.compute_txid(); let mut mempool = self.mempool.lock().await; diff --git a/crates/floresta-wire/src/p2p_wire/node_interface.rs b/crates/floresta-wire/src/p2p_wire/node_interface.rs index 7009de926..48b79c2d9 100644 --- a/crates/floresta-wire/src/p2p_wire/node_interface.rs +++ b/crates/floresta-wire/src/p2p_wire/node_interface.rs @@ -101,6 +101,33 @@ pub enum UserRequest { /// Return address manager statistics. GetAddrManInfo, + + /// Return information about manually added nodes, optionally filtered by address. + GetAddedNodeInfo(Option<(IpAddr, u16)>), +} + +#[derive(Debug, Clone, Serialize)] +/// Information about a manually added node (via `addnode`). +pub struct AddedNodeInfo { + /// The address of the added node in "ip:port" format. + pub addednode: String, + + /// Whether we are currently connected to this node. + pub connected: bool, + + /// Connection details. Only populated when `connected` is true. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub addresses: Vec, +} + +#[derive(Debug, Clone, Serialize)] +/// Address information for a connected added node. +pub struct AddedNodeAddress { + /// The peer address in "ip:port" format. + pub address: String, + + /// The connection direction: "outbound" (Floresta does not accept inbound). + pub connected: String, } #[derive(Debug, Clone, Serialize)] @@ -165,6 +192,9 @@ pub enum NodeResponse { /// Address manager statistics. GetAddrManInfo(ConnectionStats), + + /// Information about all manually added nodes. + GetAddedNodeInfo(Vec), } #[derive(Debug, Clone)] @@ -342,6 +372,18 @@ impl NodeInterface { extract_variant!(GetAddrManInfo, val) } + + /// Returns information about manually added nodes, optionally filtered by address. + pub async fn get_added_node_info( + &self, + node: Option<(IpAddr, u16)>, + ) -> Result, oneshot::error::RecvError> { + let val = self + .send_request(UserRequest::GetAddedNodeInfo(node)) + .await?; + + extract_variant!(GetAddedNodeInfo, val) + } } fn serialize_service_flags(flags: &ServiceFlags, serializer: S) -> Result From bdf2fa0399d5626d30d83496a3f28bf244595b5e Mon Sep 17 00:00:00 2001 From: jaoleal Date: Fri, 29 May 2026 17:20:26 -0300 Subject: [PATCH 2/4] feat(rpc): add getaddednodeinfo to server-side handler Register the getaddednodeinfo RPC endpoint in the JSON-RPC server: - Add get_added_node_info() handler in network.rs - Register "getaddednodeinfo" route in server.rs - Add RPC documentation --- crates/floresta-node/src/json_rpc/network.rs | 25 +++++++++++ crates/floresta-node/src/json_rpc/server.rs | 8 ++++ doc/rpc/getaddednodeinfo.md | 44 ++++++++++++++++++++ 3 files changed, 77 insertions(+) create mode 100644 doc/rpc/getaddednodeinfo.md diff --git a/crates/floresta-node/src/json_rpc/network.rs b/crates/floresta-node/src/json_rpc/network.rs index ff48d644c..adc822d7a 100644 --- a/crates/floresta-node/src/json_rpc/network.rs +++ b/crates/floresta-node/src/json_rpc/network.rs @@ -16,6 +16,7 @@ use floresta_common::advertised_services; use floresta_common::service_flags_strings; use floresta_wire::address_man::NetworkStats; use floresta_wire::address_man::ReachableNetworks; +use floresta_wire::node_interface::AddedNodeInfo; use floresta_wire::node_interface::PeerInfo; use serde_json::Value; use serde_json::json; @@ -190,6 +191,30 @@ impl RpcImpl { Ok(GetAddrManInfo(map)) } + pub(crate) async fn get_added_node_info( + &self, + node: Option, + ) -> Result> { + let parsed_node = match node { + Some(addr_str) => { + let (addr, port) = if let Ok(sa) = addr_str.parse::() { + (sa.ip(), sa.port()) + } else { + let ip = addr_str + .parse::() + .map_err(|_| JsonRpcError::InvalidAddress)?; + (ip, 8333) + }; + Some((addr, port)) + } + None => None, + }; + self.node + .get_added_node_info(parsed_node) + .await + .map_err(|e| JsonRpcError::Node(e.to_string())) + } + pub(crate) async fn get_network_info(&self) -> Result { // Floresta does not listen for inbound connections, so every peer is outbound. let connections_in = 0; diff --git a/crates/floresta-node/src/json_rpc/server.rs b/crates/floresta-node/src/json_rpc/server.rs index 4b22fafb6..18b9c0935 100644 --- a/crates/floresta-node/src/json_rpc/server.rs +++ b/crates/floresta-node/src/json_rpc/server.rs @@ -370,6 +370,14 @@ async fn handle_json_rpc_request( .await .map(|v| serde_json::to_value(v).unwrap()), + "getaddednodeinfo" => { + let node = get_optional_field(¶ms, 0, "node", get_string)?; + state + .get_added_node_info(node) + .await + .map(|v| serde_json::to_value(v).unwrap()) + } + "addnode" => { let node = get_string(¶ms, 0, "node")?; let command = get_string(¶ms, 1, "command")?; diff --git a/doc/rpc/getaddednodeinfo.md b/doc/rpc/getaddednodeinfo.md new file mode 100644 index 000000000..7e9e5f4f8 --- /dev/null +++ b/doc/rpc/getaddednodeinfo.md @@ -0,0 +1,44 @@ +# `getaddednodeinfo` + +Return information about nodes that were manually added. + +## Usage + +### Synopsis + +```text +floresta-cli getaddednodeinfo [node] +``` + +### Examples + +```bash +floresta-cli getaddednodeinfo +floresta-cli getaddednodeinfo 192.168.0.1:8333 +``` + +## Arguments + +- `node` - (string, optional) If provided, return information only about this added node. The value should be an IP address or `ip:port`. If only an IP is given, port 8333 is assumed. + +## Returns + +### Ok Response + +A JSON array of objects, one per added node: + +- `addednode` - (string) The address of the node in `ip:port` format +- `connected` - (boolean) Whether the node is currently connected +- `addresses` - (array, only when connected) Connection details: + - `address` - (string) The peer address in `ip:port` format + - `connected` - (string) The connection direction (`"outbound"`) + +### Error Response + +- `Node` - Failed to retrieve added node information +- `InvalidAddress` - The provided node address could not be parsed + +## Notes + +- Only manually nodes added will appear here; peers discovered automatically are not included. +- Floresta does not accept inbound connections yet, so the `connected` field in the `addresses` array is always `"outbound"`. From 9022e1c405c6b0f9dfe981f1606ac5ed69fa4362 Mon Sep 17 00:00:00 2001 From: jaoleal Date: Fri, 29 May 2026 17:21:10 -0300 Subject: [PATCH 3/4] feat(rpc): add rpc client command and CLI binding for getaddednodeinfo Add getaddednodeinfo to the FlorestaRPC trait and its blanket implementation for JSON-RPC clients. Wire the new method into the floresta-cli binary as a subcommand. --- bin/floresta-cli/src/main.rs | 15 +++++++++++++++ crates/floresta-rpc/src/rpc.rs | 10 ++++++++++ 2 files changed, 25 insertions(+) diff --git a/bin/floresta-cli/src/main.rs b/bin/floresta-cli/src/main.rs index df785a9e3..ee349cffb 100644 --- a/bin/floresta-cli/src/main.rs +++ b/bin/floresta-cli/src/main.rs @@ -138,6 +138,9 @@ fn do_request(cmd: &Cli, client: Client) -> anyhow::Result { serde_json::to_string_pretty(&client.get_deployment_info(blockhash)?)? } Methods::GetAddrManInfo => serde_json::to_string_pretty(&client.get_addrman_info()?)?, + Methods::GetAddedNodeInfo { node } => { + serde_json::to_string_pretty(&client.get_added_node_info(node)?)? + } }) } @@ -456,4 +459,16 @@ pub enum Methods { disable_help_subcommand = true )] GetAddrManInfo, + + #[doc = include_str!("../../../doc/rpc/getaddednodeinfo.md")] + #[command( + name = "getaddednodeinfo", + about = "Returns information about manually added nodes", + long_about = Some(include_str!("../../../doc/rpc/getaddednodeinfo.md")), + disable_help_subcommand = true + )] + GetAddedNodeInfo { + /// Filter results to a specific added node (ip or ip:port) + node: Option, + }, } diff --git a/crates/floresta-rpc/src/rpc.rs b/crates/floresta-rpc/src/rpc.rs index ebaeddae2..4c581b2ca 100644 --- a/crates/floresta-rpc/src/rpc.rs +++ b/crates/floresta-rpc/src/rpc.rs @@ -155,6 +155,9 @@ pub trait FlorestaRPC { fn ping(&self) -> Result<()>; /// Returns address manager statistics broken down by network. fn get_addrman_info(&self) -> Result; + + #[doc = include_str!("../../../doc/rpc/getaddednodeinfo.md")] + fn get_added_node_info(&self, node: Option) -> Result; } /// Since the workflow for jsonrpc is the same for all methods, we can implement a trait @@ -379,4 +382,11 @@ impl FlorestaRPC for T { fn get_addrman_info(&self) -> Result { self.call("getaddrmaninfo", &[]) } + + fn get_added_node_info(&self, node: Option) -> Result { + match node { + Some(n) => self.call("getaddednodeinfo", &[Value::String(n)]), + None => self.call("getaddednodeinfo", &[]), + } + } } From 7cf11ba5dbf4e1c4e31177bdba83c3d9d3df81b7 Mon Sep 17 00:00:00 2001 From: jaoleal Date: Fri, 29 May 2026 17:21:37 -0300 Subject: [PATCH 4/4] test(rpc): add getaddednodeinfo integration test Add Python integration test verifying getaddednodeinfo returns correct information about manually added nodes, and add the get_added_node_info() wrapper to the test framework. --- tests/floresta-cli/getaddednodeinfo.py | 159 +++++++++++++++++++++++++ tests/test_framework/rpc/base.py | 8 ++ 2 files changed, 167 insertions(+) create mode 100644 tests/floresta-cli/getaddednodeinfo.py diff --git a/tests/floresta-cli/getaddednodeinfo.py b/tests/floresta-cli/getaddednodeinfo.py new file mode 100644 index 000000000..7caa002ca --- /dev/null +++ b/tests/floresta-cli/getaddednodeinfo.py @@ -0,0 +1,159 @@ +# SPDX-License-Identifier: MIT OR Apache-2.0 + +""" +floresta_cli_getaddednodeinfo.py + +This functional test verifies the `getaddednodeinfo` RPC method. +""" + +import pytest +from requests.exceptions import HTTPError +from test_framework.node import NodeType + + +@pytest.mark.rpc +def test_get_added_node_info_empty(florestad_node): + """ + With no manually added nodes, getaddednodeinfo returns an empty list. + """ + info = florestad_node.rpc.get_added_node_info() + assert isinstance(info, list) + assert len(info) == 0 + + +@pytest.mark.rpc +def test_get_added_node_info_connected( + node_manager, florestad_node, add_node_with_extra_args +): + """ + After adding a node via addnode and connecting, getaddednodeinfo should + report the node as connected with a populated addresses array. + """ + bitcoind = add_node_with_extra_args(NodeType.BITCOIND, ["-v2transport=0"]) + + florestad_node.rpc.addnode(bitcoind.p2p_url, "add", v2transport=False) + node_manager.wait_for_peers_connections(florestad_node, bitcoind) + + info = florestad_node.rpc.get_added_node_info() + assert len(info) == 1 + + entry = info[0] + assert entry["addednode"] == bitcoind.p2p_url + assert entry["connected"] is True + + # The addresses array must be present and populated when connected + assert "addresses" in entry + assert len(entry["addresses"]) == 1 + assert entry["addresses"][0]["address"] == bitcoind.p2p_url + assert entry["addresses"][0]["connected"] == "outbound" + + +@pytest.mark.rpc +def test_get_added_node_info_multiple_nodes( + node_manager, florestad_node, add_node_with_extra_args +): + """ + Adding multiple nodes via addnode should all appear in getaddednodeinfo. + """ + bitcoind_a = add_node_with_extra_args(NodeType.BITCOIND, ["-v2transport=0"]) + bitcoind_b = add_node_with_extra_args(NodeType.BITCOIND, ["-v2transport=0"]) + + florestad_node.rpc.addnode(bitcoind_a.p2p_url, "add", v2transport=False) + florestad_node.rpc.addnode(bitcoind_b.p2p_url, "add", v2transport=False) + node_manager.wait_for_peers_connections(florestad_node, bitcoind_a) + node_manager.wait_for_peers_connections(florestad_node, bitcoind_b) + + info = florestad_node.rpc.get_added_node_info() + assert len(info) == 2 + + added_addresses = {entry["addednode"] for entry in info} + assert bitcoind_a.p2p_url in added_addresses + assert bitcoind_b.p2p_url in added_addresses + + # Both should be connected with addresses populated + for entry in info: + assert entry["connected"] is True + assert len(entry["addresses"]) == 1 + assert entry["addresses"][0]["connected"] == "outbound" + + +@pytest.mark.rpc +def test_get_added_node_info_filter_by_node( + node_manager, florestad_node, add_node_with_extra_args +): + """ + The optional node parameter should filter results to a single entry. + """ + bitcoind_a = add_node_with_extra_args(NodeType.BITCOIND, ["-v2transport=0"]) + bitcoind_b = add_node_with_extra_args(NodeType.BITCOIND, ["-v2transport=0"]) + + florestad_node.rpc.addnode(bitcoind_a.p2p_url, "add", v2transport=False) + florestad_node.rpc.addnode(bitcoind_b.p2p_url, "add", v2transport=False) + node_manager.wait_for_peers_connections(florestad_node, bitcoind_a) + node_manager.wait_for_peers_connections(florestad_node, bitcoind_b) + + # Filter for node A only + filtered = florestad_node.rpc.get_added_node_info(bitcoind_a.p2p_url) + assert len(filtered) == 1 + assert filtered[0]["addednode"] == bitcoind_a.p2p_url + + # Filter for node B only + filtered = florestad_node.rpc.get_added_node_info(bitcoind_b.p2p_url) + assert len(filtered) == 1 + assert filtered[0]["addednode"] == bitcoind_b.p2p_url + + # Filter for non-existent node returns empty list + filtered_empty = florestad_node.rpc.get_added_node_info("1.2.3.4:9999") + assert len(filtered_empty) == 0 + + +@pytest.mark.rpc +def test_get_added_node_info_after_remove( + node_manager, florestad_node, add_node_with_extra_args +): + """ + After removing a manually added node, it should no longer appear in getaddednodeinfo. + """ + bitcoind = add_node_with_extra_args(NodeType.BITCOIND, ["-v2transport=0"]) + + florestad_node.rpc.addnode(bitcoind.p2p_url, "add", v2transport=False) + node_manager.wait_for_peers_connections(florestad_node, bitcoind) + + info = florestad_node.rpc.get_added_node_info() + assert len(info) == 1 + + florestad_node.rpc.addnode(bitcoind.p2p_url, "remove") + + info = florestad_node.rpc.get_added_node_info() + assert len(info) == 0 + + +@pytest.mark.rpc +def test_get_added_node_info_connect_flag_not_listed( + node_manager, add_node_with_extra_args +): + """ + Peers added via --connect should NOT appear in getaddednodeinfo. + Only peers added via the addnode RPC are listed. + """ + bitcoind = add_node_with_extra_args(NodeType.BITCOIND, ["-v2transport=0"]) + florestad = add_node_with_extra_args( + NodeType.FLORESTAD, + [f"--connect={bitcoind.p2p_url}"], + ) + + node_manager.wait_for_peers_connections(florestad, bitcoind) + + # The --connect peer must NOT appear in getaddednodeinfo + info = florestad.rpc.get_added_node_info() + assert isinstance(info, list) + assert len(info) == 0 + + +@pytest.mark.rpc +def test_get_added_node_info_invalid_node_filter(florestad_node): + """ + Passing an invalid address as the node filter should return an error. + """ + with pytest.raises(HTTPError): + florestad_node.rpc.get_added_node_info("not-a-valid-address") diff --git a/tests/test_framework/rpc/base.py b/tests/test_framework/rpc/base.py index a14486cce..cb2ac320f 100644 --- a/tests/test_framework/rpc/base.py +++ b/tests/test_framework/rpc/base.py @@ -349,6 +349,14 @@ def ping(self): """ return self.perform_request("ping") + def get_added_node_info(self, node: Optional[str] = None) -> list: + """ + Get information about manually added nodes. + node: optional filter — return only this added node's info + """ + params = [node] if node is not None else [] + return self.perform_request("getaddednodeinfo", params) + def disconnectnode(self, node_address: str, node_id: Optional[int] = None): """ Disconnect from a peer by `node_address` or `node_id`