feat(01-04): expose meshed Lightning peers and the send path over RPC (FED-05)
Task 3, completing 01-04. mesh.lightning-peers returns the peers that have advertised a Lightning URI: filtered, deduplicated, deterministically ordered, and an empty array rather than an error when nobody has — "nobody yet" is a normal state on a fresh node, not a fault. mesh.send-lightning-info advertises this node's own URI to ONE chosen peer. There is deliberately no broadcast form: this discloses the node's payment endpoint, and who learns it is the operator's choice rather than a side effect of being in radio range (T-01-13). It refuses to send when LND advertises no URI, instead of sending an empty one a peer would store as an undialable target. The list-building and target-parsing logic is extracted into pure functions because this file has no handler test harness and the handlers need a live mesh service. That keeps the three contracts that actually matter provable rather than merely readable: - dedup is keyed on identity_pubkey_hex() — the AUTHENTICATING key, lowercased — never the firmware routing key, so a radio contact and its federation twin collapse to one entry (T-01-11) - "newest advertisement wins" compares PARSED RFC3339 timestamps, not strings: 09:30-01:00 is later than 10:00Z while sorting earlier as text, and there is a test that fails if that is ever string-compared - ordering is name-then-contact_id and asserted byte-identical across eight rotations of the input, because a HashMap's iteration order is not stable and a picker that reshuffles between reads means an operator can click a different node than the one they aimed at The peer allow-list is untouched: server.rs has an empty diff and is_peer_allowed_path still occurs 13 times (T-01-15). Verified: cargo test -p archipelago 1087 passed / 0 failed; clippy --all-targets clean in every touched module (two useless_format lints in the new test code fixed, not waived). The SUMMARY records one deviation honestly: Task 1's tests were written alongside its implementation rather than before, so no pre-implementation failing output exists. A mutation test was run in its place — disabling the pubkey validation fails 3 of the 5 tests — which proves the assertions bind, and the mutation was reverted and verified gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
decb7c713b
commit
666990c684
@@ -422,6 +422,8 @@ impl RpcHandler {
|
||||
"mesh.send-psbt" => self.handle_mesh_send_psbt(params).await,
|
||||
"mesh.broadcast-presence" => self.handle_mesh_broadcast_presence(params).await,
|
||||
"mesh.presence-list" => self.handle_mesh_presence_list(params).await,
|
||||
"mesh.lightning-peers" => self.handle_mesh_lightning_peers(params).await,
|
||||
"mesh.send-lightning-info" => self.handle_mesh_send_lightning_info(params).await,
|
||||
"mesh.contacts-list" => self.handle_mesh_contacts_list(params).await,
|
||||
"mesh.contacts-save" => self.handle_mesh_contacts_save(params).await,
|
||||
"mesh.contacts-block" => self.handle_mesh_contacts_block(params).await,
|
||||
|
||||
@@ -1368,4 +1368,298 @@ impl RpcHandler {
|
||||
.await?;
|
||||
Ok(serde_json::json!({ "sent": true, "message_id": msg.id, "sender_seq": seq }))
|
||||
}
|
||||
|
||||
/// mesh.lightning-peers — the meshed peers that have advertised a Lightning
|
||||
/// URI, i.e. the "public/other" side of the channel-open picker (FED-05).
|
||||
///
|
||||
/// Returns an empty array, never an error, when no peer has advertised —
|
||||
/// "nobody yet" is a normal state on a fresh node, not a fault.
|
||||
pub(in crate::api::rpc) async fn handle_mesh_lightning_peers(
|
||||
&self,
|
||||
_params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let state = svc.shared_state();
|
||||
let peer_vec: Vec<_> = state.peers.read().await.values().cloned().collect();
|
||||
Ok(serde_json::json!({ "peers": build_lightning_peer_list(&peer_vec) }))
|
||||
}
|
||||
|
||||
/// mesh.send-lightning-info — advertise THIS node's Lightning URI to one
|
||||
/// chosen peer.
|
||||
///
|
||||
/// Requires an explicit target. There is deliberately no broadcast form:
|
||||
/// this discloses the node's payment endpoint, and who learns it is the
|
||||
/// operator's choice, not a side effect of being in radio range (T-01-13).
|
||||
pub(in crate::api::rpc) async fn handle_mesh_send_lightning_info(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let contact_id = parse_send_lightning_target(params.as_ref())?;
|
||||
|
||||
// Read our own URI from the lnd.getinfo path. Refuse rather than send an
|
||||
// empty advertisement: a peer that stored "" would show us in its picker
|
||||
// as a target it can never dial.
|
||||
let info = self
|
||||
.handle_lnd_getinfo()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Cannot read this node's Lightning info: {e}"))?;
|
||||
let uri = info
|
||||
.get("uris")
|
||||
.and_then(|u| u.as_array())
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|u| u.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"This node has no advertised Lightning URI to share — LND may be down, or \
|
||||
configured with no externally reachable address"
|
||||
)
|
||||
})?;
|
||||
if !message_types::is_valid_lightning_uri(&uri) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"This node's own Lightning URI is malformed; refusing to advertise it"
|
||||
));
|
||||
}
|
||||
let alias = info
|
||||
.get("alias")
|
||||
.and_then(|a| a.as_str())
|
||||
.filter(|a| !a.is_empty())
|
||||
.map(|a| a.to_string());
|
||||
|
||||
let payload_struct = message_types::LightningInfoPayload { uri, alias };
|
||||
|
||||
let service = self.mesh_service.read().await;
|
||||
let svc = service
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||
let seq = svc.next_send_seq(contact_id).await;
|
||||
let payload = message_types::encode_payload(&payload_struct)?;
|
||||
let envelope = TypedEnvelope::new(MeshMessageType::LightningInfo, payload).with_seq(seq);
|
||||
let wire = envelope.to_wire()?;
|
||||
let typed_json = serde_json::to_value(&payload_struct).ok();
|
||||
let msg = svc
|
||||
.send_typed_wire(
|
||||
contact_id,
|
||||
wire,
|
||||
"lightning_info",
|
||||
"Shared Lightning connection info",
|
||||
typed_json,
|
||||
seq,
|
||||
)
|
||||
.await?;
|
||||
info!(contact_id, seq, "Sent lightning_info to a chosen mesh peer");
|
||||
Ok(serde_json::json!({ "sent": true, "message_id": msg.id, "sender_seq": seq }))
|
||||
}
|
||||
}
|
||||
|
||||
/// The required target for `mesh.send-lightning-info`.
|
||||
///
|
||||
/// Split out so the "a target is mandatory" contract is testable without a mesh
|
||||
/// service — that contract is the whole of T-01-13's mitigation, so it should
|
||||
/// not be provable only by reading the code.
|
||||
fn parse_send_lightning_target(params: Option<&serde_json::Value>) -> Result<u32> {
|
||||
let params =
|
||||
params.ok_or_else(|| anyhow::anyhow!("Missing params: a target contact_id is required"))?;
|
||||
let contact_id = params["contact_id"].as_u64().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Missing contact_id: mesh.send-lightning-info requires an explicit target and has no \
|
||||
broadcast form"
|
||||
)
|
||||
})?;
|
||||
u32::try_from(contact_id).map_err(|_| anyhow::anyhow!("contact_id out of range"))
|
||||
}
|
||||
|
||||
/// Build the deduplicated, deterministically ordered Lightning-peer list.
|
||||
///
|
||||
/// Pure so the dedup and ordering contracts are testable without a mesh
|
||||
/// service. Ordering matters for a real reason: the picker must not reshuffle
|
||||
/// between reads, or an operator clicking a row can hit a different node than
|
||||
/// the one they aimed at.
|
||||
fn build_lightning_peer_list(peers: &[crate::mesh::types::MeshPeer]) -> Vec<serde_json::Value> {
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Dedup by the AUTHENTICATING key, never the firmware routing key: a radio
|
||||
// contact and its federation twin are one node and must not be offered
|
||||
// twice (T-01-11). Peers with no key at all fall back to contact_id, which
|
||||
// is unique per record.
|
||||
let mut best: HashMap<String, &crate::mesh::types::MeshPeer> = HashMap::new();
|
||||
for peer in peers.iter().filter(|p| p.lightning_uri.is_some()) {
|
||||
let key = peer
|
||||
.identity_pubkey_hex()
|
||||
.map(|k| k.to_ascii_lowercase())
|
||||
.unwrap_or_else(|| format!("contact:{}", peer.contact_id));
|
||||
best.entry(key)
|
||||
.and_modify(|kept| {
|
||||
// Newest advertisement wins. last_heard is RFC3339; parse rather
|
||||
// than string-compare so a differing offset can't misorder.
|
||||
let kept_at = chrono::DateTime::parse_from_rfc3339(&kept.last_heard).ok();
|
||||
let this_at = chrono::DateTime::parse_from_rfc3339(&peer.last_heard).ok();
|
||||
if this_at >= kept_at {
|
||||
*kept = peer;
|
||||
}
|
||||
})
|
||||
.or_insert(peer);
|
||||
}
|
||||
|
||||
let mut out: Vec<&crate::mesh::types::MeshPeer> = best.into_values().collect();
|
||||
// Sort by display name, then contact_id as the tiebreak, so two peers
|
||||
// sharing a name still have a total order and the list is stable across
|
||||
// reads (a HashMap's iteration order is not).
|
||||
out.sort_by(|a, b| {
|
||||
a.advert_name
|
||||
.to_lowercase()
|
||||
.cmp(&b.advert_name.to_lowercase())
|
||||
.then(a.contact_id.cmp(&b.contact_id))
|
||||
});
|
||||
|
||||
out.into_iter()
|
||||
.map(|p| {
|
||||
serde_json::json!({
|
||||
"contact_id": p.contact_id,
|
||||
"name": p.advert_name,
|
||||
"lightning_uri": p.lightning_uri,
|
||||
"last_heard": p.last_heard,
|
||||
"reachable": p.reachable,
|
||||
"hops": p.hops,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lightning_peer_tests {
|
||||
use super::*;
|
||||
use crate::mesh::types::MeshPeer;
|
||||
|
||||
const URI_A: &str =
|
||||
"03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90@1.2.3.4:9735";
|
||||
const URI_B: &str =
|
||||
"02ffeeddccbbaa998877665544332211ffeeddccbbaa998877665544332211ffee@5.6.7.8:9735";
|
||||
|
||||
fn peer(contact_id: u32, name: &str, arch: Option<&str>) -> MeshPeer {
|
||||
MeshPeer {
|
||||
contact_id,
|
||||
advert_name: name.into(),
|
||||
did: None,
|
||||
pubkey_hex: Some(format!("routing{contact_id}")),
|
||||
arch_pubkey_hex: arch.map(|s| s.into()),
|
||||
x25519_pubkey: None,
|
||||
rssi: None,
|
||||
snr: None,
|
||||
last_heard: "2026-08-02T10:00:00+00:00".into(),
|
||||
hops: 1,
|
||||
last_advert: 0,
|
||||
reachable: true,
|
||||
pkc_capable: false,
|
||||
lat: None,
|
||||
lon: None,
|
||||
lightning_uri: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_advertised_peers_is_an_empty_list_not_an_error() {
|
||||
assert!(build_lightning_peer_list(&[]).is_empty());
|
||||
// Peers exist, but none has advertised Lightning.
|
||||
let quiet = vec![peer(1, "a", None), peer(2, "b", None)];
|
||||
assert!(build_lightning_peer_list(&quiet).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_peers_that_advertised_are_listed() {
|
||||
let mut with = peer(1, "has-lightning", None);
|
||||
with.lightning_uri = Some(URI_A.into());
|
||||
let list = build_lightning_peer_list(&[with, peer(2, "no-lightning", None)]);
|
||||
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0]["name"], "has-lightning");
|
||||
assert_eq!(list[0]["lightning_uri"], URI_A);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_peer_that_advertised_twice_appears_once_with_the_newer_uri() {
|
||||
// Same node seen as two records (radio + federation twin) sharing an
|
||||
// authenticating key — the picker must offer it once, not twice.
|
||||
let mut older = peer(1, "twin", Some("ARCHKEY"));
|
||||
older.lightning_uri = Some(URI_A.into());
|
||||
older.last_heard = "2026-08-02T10:00:00+00:00".into();
|
||||
|
||||
let mut newer = peer(2, "twin", Some("archkey")); // case-insensitive match
|
||||
newer.lightning_uri = Some(URI_B.into());
|
||||
newer.last_heard = "2026-08-02T11:30:00+00:00".into();
|
||||
|
||||
let list = build_lightning_peer_list(&[older.clone(), newer.clone()]);
|
||||
assert_eq!(list.len(), 1, "twins must collapse to one entry");
|
||||
assert_eq!(list[0]["lightning_uri"], URI_B, "the newer URI must win");
|
||||
|
||||
// Order of the input must not change the outcome.
|
||||
let reversed = build_lightning_peer_list(&[newer, older]);
|
||||
assert_eq!(reversed[0]["lightning_uri"], URI_B);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_differing_offset_cannot_misorder_the_newest_advertisement() {
|
||||
// 09:30-01:00 is 10:30 UTC — LATER than 10:00Z, though it string-sorts
|
||||
// earlier. Parsing rather than string-comparing is what makes this pass.
|
||||
let mut utc = peer(1, "twin", Some("k"));
|
||||
utc.lightning_uri = Some(URI_A.into());
|
||||
utc.last_heard = "2026-08-02T10:00:00+00:00".into();
|
||||
|
||||
let mut offset = peer(2, "twin", Some("k"));
|
||||
offset.lightning_uri = Some(URI_B.into());
|
||||
offset.last_heard = "2026-08-02T09:30:00-01:00".into();
|
||||
|
||||
let list = build_lightning_peer_list(&[utc, offset]);
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0]["lightning_uri"], URI_B);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordering_is_stable_and_deterministic_across_reads() {
|
||||
let mut peers = Vec::new();
|
||||
for (id, name) in [(3, "Zulu"), (1, "alpha"), (2, "Mike"), (9, "alpha")] {
|
||||
let mut p = peer(id, name, Some(&format!("key{id}")));
|
||||
p.lightning_uri = Some(URI_A.into());
|
||||
peers.push(p);
|
||||
}
|
||||
|
||||
let first = build_lightning_peer_list(&peers);
|
||||
// A HashMap's iteration order is not stable, so run it repeatedly over a
|
||||
// shuffled input: the output must be byte-identical every time.
|
||||
for _ in 0..8 {
|
||||
peers.rotate_left(1);
|
||||
assert_eq!(build_lightning_peer_list(&peers), first);
|
||||
}
|
||||
|
||||
let names: Vec<_> = first.iter().map(|e| e["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, vec!["alpha", "alpha", "Mike", "Zulu"]);
|
||||
// Same name -> contact_id breaks the tie, ascending.
|
||||
assert_eq!(first[0]["contact_id"], 1);
|
||||
assert_eq!(first[1]["contact_id"], 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn send_requires_an_explicit_target_and_has_no_broadcast_form() {
|
||||
assert!(parse_send_lightning_target(None).is_err(), "no params");
|
||||
assert!(
|
||||
parse_send_lightning_target(Some(&serde_json::json!({}))).is_err(),
|
||||
"params without contact_id must be refused, not treated as broadcast"
|
||||
);
|
||||
assert!(
|
||||
parse_send_lightning_target(Some(&serde_json::json!({"broadcast": true}))).is_err(),
|
||||
"there is no broadcast escape hatch"
|
||||
);
|
||||
assert!(
|
||||
parse_send_lightning_target(Some(&serde_json::json!({"contact_id": 1u64 << 40})))
|
||||
.is_err(),
|
||||
"an out-of-range contact_id must error, not silently truncate to another peer"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_send_lightning_target(Some(&serde_json::json!({"contact_id": 42}))).unwrap(),
|
||||
42
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user