feat(01-04): the two Lightning facts the channel-open picker needs (FED-05)
Tasks 1 and 2 of 01-04. This node's own shareable URI, and a mesh message a peer uses to advertise theirs. lnd.getinfo now deserializes identity_pubkey and uris, which its response struct simply did not declare before (RESEARCH.md Pitfall 5). The identity mapping is split into a pure map_identity() so it is testable without a live LND. A pubkey that is not 66 hex characters maps to None rather than being forwarded: the same rule lnd.openchannel enforces, applied where the operator is reading their own node's identity instead of at the moment they try to open a channel. An absent field yields an honest absence — never a fabricated or placeholder identity. MeshMessageType::LightningInfo = 26 is additive on a wire format shared with every fleet node: 26 was unused, so a peer that predates this fails to decode it rather than mis-decoding it as something else. Its payload is deliberately two fields — this rides LoRa, where every byte is paid for on air, and the optional alias is skip_serializing_if so an absent one costs nothing (asserted, not assumed). is_valid_lightning_uri() validates before anything is stored, because this is unauthenticated RF input: 66-hex pubkey, non-empty host, optional numeric :port, exactly one '@'. It deliberately does NOT resolve or dial the host — that would turn a received advertisement into an outbound connection an attacker chose. Two preservation hazards found while wiring MeshPeer.lightning_uri, both of which would have silently emptied the picker: - decode.rs's identity-advert path does a WHOLESALE insert, preserving only advert_name and lat/lon by hand. Reticulum re-emits identity adverts every announce tick, so a stored URI would have been wiped about once a minute. Now preserved, alongside the same guard the name and position already had. - session.rs's refresh_contacts and mod.rs's federation seeding rebuild the peer record wholesale too. Neither carries a Lightning datum, so both now carry the previous value forward rather than nulling it. A malformed inbound URI is rejected before the write, leaving any previously stored good URI intact — otherwise anyone in range could blank out a real peer's picker entry (T-01-12). Verified: 5/5 new lnd::info tests, 18/18 mesh::message_types (5 new), cargo check --all-targets clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e205f2c34a
commit
decb7c713b
@@ -15,6 +15,13 @@ struct LndInfo {
|
||||
balance_sats: i64,
|
||||
channel_balance_sats: i64,
|
||||
pending_open_balance: i64,
|
||||
/// This node's Lightning identity pubkey, or `None` when LND did not
|
||||
/// report one or reported one that is not a compressed secp256k1 key.
|
||||
/// Never fabricated: the caller can tell "not available" from "available".
|
||||
identity_pubkey: Option<String>,
|
||||
/// The connection URIs LND advertises for this node (`pubkey@host:port`).
|
||||
/// Empty when LND advertises none — an honest absence, not a placeholder.
|
||||
uris: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -24,6 +31,40 @@ struct LndGetInfoResponse {
|
||||
num_peers: Option<u32>,
|
||||
synced_to_chain: Option<bool>,
|
||||
block_height: Option<u64>,
|
||||
#[serde(default)]
|
||||
identity_pubkey: Option<String>,
|
||||
#[serde(default)]
|
||||
uris: Vec<String>,
|
||||
}
|
||||
|
||||
/// A compressed secp256k1 pubkey is 66 hexadecimal characters. Mirrors the
|
||||
/// check `handle_lnd_openchannel` performs before dialling a peer, so a key
|
||||
/// this function passes is one that handler would accept.
|
||||
fn is_valid_identity_pubkey(pubkey: &str) -> bool {
|
||||
pubkey.len() == 66 && pubkey.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Map LND's reported identity onto the RPC response.
|
||||
///
|
||||
/// Split out from the HTTP flow so it is testable without a live LND. A
|
||||
/// malformed pubkey yields `None` rather than propagating a key that
|
||||
/// `lnd.openchannel` would later reject — surfacing the problem here, where
|
||||
/// the operator is reading their own node's identity, beats surfacing it at
|
||||
/// the moment they try to open a channel.
|
||||
fn map_identity(get_info: &LndGetInfoResponse) -> (Option<String>, Vec<String>) {
|
||||
let identity_pubkey = match get_info.identity_pubkey.as_deref() {
|
||||
Some(pubkey) if is_valid_identity_pubkey(pubkey) => Some(pubkey.to_string()),
|
||||
Some(bad) => {
|
||||
tracing::warn!(
|
||||
len = bad.len(),
|
||||
"LND getinfo returned an identity_pubkey that is not 66 hex characters — \
|
||||
reporting no identity rather than a key lnd.openchannel would reject"
|
||||
);
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
(identity_pubkey, get_info.uris.clone())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -84,7 +125,11 @@ impl RpcHandler {
|
||||
},
|
||||
};
|
||||
|
||||
let (identity_pubkey, uris) = map_identity(&get_info);
|
||||
|
||||
let info = LndInfo {
|
||||
identity_pubkey,
|
||||
uris,
|
||||
alias: get_info.alias.unwrap_or_default(),
|
||||
num_active_channels: get_info.num_active_channels.unwrap_or(0),
|
||||
num_peers: get_info.num_peers.unwrap_or(0),
|
||||
@@ -218,3 +263,78 @@ impl RpcHandler {
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A real compressed secp256k1 pubkey shape: 66 hex characters.
|
||||
const GOOD_PUBKEY: &str =
|
||||
"03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
|
||||
|
||||
fn parse(body: &str) -> LndGetInfoResponse {
|
||||
serde_json::from_str(body).expect("LND getinfo body must deserialize")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_body_yields_identity_and_uris() {
|
||||
let parsed = parse(&format!(
|
||||
r#"{{"alias":"archy","identity_pubkey":"{GOOD_PUBKEY}",
|
||||
"uris":["{GOOD_PUBKEY}@1.2.3.4:9735","{GOOD_PUBKEY}@abcd.onion:9735"]}}"#
|
||||
));
|
||||
let (pubkey, uris) = map_identity(&parsed);
|
||||
|
||||
assert_eq!(pubkey.as_deref(), Some(GOOD_PUBKEY));
|
||||
assert_eq!(uris.len(), 2, "both advertised URIs must survive the mapping");
|
||||
assert!(uris[0].starts_with(GOOD_PUBKEY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_fields_yield_honest_absence_not_a_fabricated_identity() {
|
||||
// The pre-existing fields must still deserialize with the new ones absent —
|
||||
// this is the body every node running an older LND build returns.
|
||||
let parsed = parse(r#"{"alias":"archy","num_peers":3,"synced_to_chain":true}"#);
|
||||
let (pubkey, uris) = map_identity(&parsed);
|
||||
|
||||
assert!(pubkey.is_none(), "must not invent an identity");
|
||||
assert!(uris.is_empty(), "must not invent a URI");
|
||||
assert_eq!(parsed.alias.as_deref(), Some("archy"));
|
||||
assert_eq!(parsed.num_peers, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_pubkey_is_dropped_rather_than_propagated() {
|
||||
// Too short, non-hex, and empty must all be refused. Propagating any of
|
||||
// them would push the failure to lnd.openchannel, far from the cause.
|
||||
for bad in ["deadbeef", "", &"z".repeat(66), &GOOD_PUBKEY[..65]] {
|
||||
let parsed = parse(&format!(r#"{{"identity_pubkey":"{bad}"}}"#));
|
||||
let (pubkey, _) = map_identity(&parsed);
|
||||
assert!(
|
||||
pubkey.is_none(),
|
||||
"malformed pubkey {bad:?} must map to None, not be forwarded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_pubkey_does_not_discard_the_advertised_uris() {
|
||||
// The two facts are independent: a bad identity must not silently cost
|
||||
// the caller the URI list, which is the datum the picker actually needs.
|
||||
let parsed = parse(&format!(
|
||||
r#"{{"identity_pubkey":"nope","uris":["{GOOD_PUBKEY}@1.2.3.4:9735"]}}"#
|
||||
));
|
||||
let (pubkey, uris) = map_identity(&parsed);
|
||||
|
||||
assert!(pubkey.is_none());
|
||||
assert_eq!(uris.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_pubkey_shape_matches_the_openchannel_rule() {
|
||||
assert!(is_valid_identity_pubkey(GOOD_PUBKEY));
|
||||
assert!(is_valid_identity_pubkey(&"0".repeat(66)));
|
||||
assert!(!is_valid_identity_pubkey(&"0".repeat(65)));
|
||||
assert!(!is_valid_identity_pubkey(&"0".repeat(67)));
|
||||
assert!(!is_valid_identity_pubkey(&"g".repeat(66)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,6 +345,8 @@ pub(super) async fn resolve_peer(state: &Arc<MeshState>, sender_prefix: &str) ->
|
||||
pkc_capable: false,
|
||||
lat: None,
|
||||
lon: None,
|
||||
// Set only by an explicit LightningInfo advert, never inferred.
|
||||
lightning_uri: None,
|
||||
};
|
||||
let is_new = {
|
||||
let mut peers = state.peers.write().await;
|
||||
@@ -602,6 +604,8 @@ pub(super) async fn handle_identity_received(
|
||||
pkc_capable: false,
|
||||
lat: None,
|
||||
lon: None,
|
||||
// Set only by an explicit LightningInfo advert, never inferred.
|
||||
lightning_uri: None,
|
||||
};
|
||||
|
||||
let is_new = {
|
||||
@@ -624,6 +628,14 @@ pub(super) async fn handle_identity_received(
|
||||
peer.lat = existing.lat;
|
||||
peer.lon = existing.lon;
|
||||
}
|
||||
// Same hazard as the name and position above: an identity advert
|
||||
// carries no Lightning datum, and Reticulum re-emits one every
|
||||
// announce tick, so a wholesale insert would drop a peer out of the
|
||||
// channel-open picker about once a minute. The URI comes only from
|
||||
// an explicit LightningInfo advert — preserve it.
|
||||
if peer.lightning_uri.is_none() {
|
||||
peer.lightning_uri = existing.lightning_uri.clone();
|
||||
}
|
||||
}
|
||||
peers.insert(contact_id, peer.clone());
|
||||
is_new
|
||||
|
||||
@@ -485,6 +485,41 @@ pub(crate) async fn handle_typed_envelope_direct(
|
||||
}
|
||||
}
|
||||
|
||||
Some(MeshMessageType::LightningInfo) => {
|
||||
match message_types::decode_payload::<message_types::LightningInfoPayload>(&envelope.v)
|
||||
{
|
||||
Ok(info) => {
|
||||
// Validate BEFORE touching stored state. This arrives over
|
||||
// unauthenticated RF, and a peer that has already given us a
|
||||
// good URI must not lose it to a later malformed one —
|
||||
// otherwise anyone in range could blank out a real peer's
|
||||
// entry in the channel-open picker (T-01-12).
|
||||
if !message_types::is_valid_lightning_uri(&info.uri) {
|
||||
warn!(
|
||||
"Rejecting malformed lightning_info URI from contact {} — \
|
||||
keeping any previously stored URI",
|
||||
sender_contact_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
let mut peers = state.peers.write().await;
|
||||
if let Some(peer) = peers.get_mut(&sender_contact_id) {
|
||||
// Newest advertisement wins: a node that moves host or
|
||||
// rotates its port re-advertises, and the stale entry
|
||||
// would just fail to dial. Overwrite, never accumulate.
|
||||
peer.lightning_uri = Some(info.uri);
|
||||
} else {
|
||||
warn!(
|
||||
"lightning_info from unknown contact {} — dropped (a peer record is \
|
||||
created by identity/contact discovery, not by this advertisement)",
|
||||
sender_contact_id
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => warn!("Failed to decode lightning_info payload: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
Some(MeshMessageType::ChannelInvite) => {
|
||||
match message_types::decode_payload::<message_types::ChannelInvitePayload>(&envelope.v)
|
||||
{
|
||||
|
||||
@@ -828,6 +828,11 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
|
||||
// it just because a refresh's snapshot didn't carry one.
|
||||
lat: contact.lat.or_else(|| existing.and_then(|p| p.lat)),
|
||||
lon: contact.lon.or_else(|| existing.and_then(|p| p.lon)),
|
||||
// A contact refresh carries no Lightning datum — it comes
|
||||
// only from an explicit LightningInfo advert. Preserve what
|
||||
// was advertised, or a routine refresh would silently empty
|
||||
// the channel-open picker.
|
||||
lightning_uri: existing.and_then(|p| p.lightning_uri.clone()),
|
||||
};
|
||||
peers.insert(contact_id, peer);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,16 @@ pub enum MeshMessageType {
|
||||
/// Reply to an AssistQuery — a chunk of the LLM's answer, addressed back to
|
||||
/// the asker by `req_id`. Long answers span multiple chunks (`seq`/`done`).
|
||||
AssistResponse = 25,
|
||||
/// "I run Lightning, and this is how to reach me" — advertises the sender's
|
||||
/// LND connection URI so the recipient can offer it as a channel-open
|
||||
/// target.
|
||||
///
|
||||
/// Only ever sent on an explicit operator action against a chosen peer
|
||||
/// (`mesh.send-lightning-info`). It is never auto-broadcast to contacts in
|
||||
/// range, and a received advertisement is never re-advertised onward — the
|
||||
/// URI is this node's payment endpoint, and who learns it is the operator's
|
||||
/// choice (T-01-13).
|
||||
LightningInfo = 26,
|
||||
}
|
||||
|
||||
impl MeshMessageType {
|
||||
@@ -107,6 +117,7 @@ impl MeshMessageType {
|
||||
23 => Some(Self::ContentInline),
|
||||
24 => Some(Self::AssistQuery),
|
||||
25 => Some(Self::AssistResponse),
|
||||
26 => Some(Self::LightningInfo),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -142,6 +153,7 @@ impl MeshMessageType {
|
||||
"content_inline" => Some(Self::ContentInline),
|
||||
"assist_query" => Some(Self::AssistQuery),
|
||||
"assist_response" => Some(Self::AssistResponse),
|
||||
"lightning_info" => Some(Self::LightningInfo),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -174,6 +186,7 @@ impl MeshMessageType {
|
||||
Self::ContentInline => "content_inline",
|
||||
Self::AssistQuery => "assist_query",
|
||||
Self::AssistResponse => "assist_response",
|
||||
Self::LightningInfo => "lightning_info",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -729,6 +742,57 @@ pub struct PresencePayload {
|
||||
pub last_active: u32,
|
||||
}
|
||||
|
||||
/// LightningInfo — the sender's LND connection URI, so the recipient can offer
|
||||
/// it as a channel-open target.
|
||||
///
|
||||
/// `uri` is the standard `pubkey@host:port` form (the `:port` suffix is
|
||||
/// optional). `alias` is the node's human-readable name, carried so the picker
|
||||
/// can label the entry without a second round trip; it is advisory and
|
||||
/// unverified, so it must never be used as an identity.
|
||||
///
|
||||
/// Kept to two fields on purpose: this rides LoRa, where every byte is paid for
|
||||
/// on air.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LightningInfoPayload {
|
||||
pub uri: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub alias: Option<String>,
|
||||
}
|
||||
|
||||
/// Is this a well-formed Lightning connection URI (`pubkey@host` or
|
||||
/// `pubkey@host:port`)?
|
||||
///
|
||||
/// Validated before anything is stored, because this arrives over unauthenticated
|
||||
/// RF: the pubkey part must be 66 hexadecimal characters (a compressed secp256k1
|
||||
/// key, the same rule `lnd.openchannel` enforces) and the host part must be
|
||||
/// non-empty. Deliberately does NOT resolve or dial the host — that would turn a
|
||||
/// received advertisement into an outbound connection an attacker chose.
|
||||
pub fn is_valid_lightning_uri(uri: &str) -> bool {
|
||||
// Exactly one '@': "a@b@c" must not pass by splitting on the first one.
|
||||
let mut parts = uri.split('@');
|
||||
let (Some(pubkey), Some(host), None) = (parts.next(), parts.next(), parts.next()) else {
|
||||
return false;
|
||||
};
|
||||
if pubkey.len() != 66 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return false;
|
||||
}
|
||||
// Strip an optional :port and require the remaining host to be non-empty.
|
||||
// rsplit_once so IPv6-ish hosts don't lose their body to the first colon.
|
||||
let host_only = match host.rsplit_once(':') {
|
||||
Some((h, port)) => {
|
||||
if port.is_empty() || !port.chars().all(|c| c.is_ascii_digit()) {
|
||||
return false;
|
||||
}
|
||||
if port.parse::<u16>().is_err() {
|
||||
return false;
|
||||
}
|
||||
h
|
||||
}
|
||||
None => host,
|
||||
};
|
||||
!host_only.is_empty() && !host_only.contains(char::is_whitespace)
|
||||
}
|
||||
|
||||
/// ChannelInvite — advertise/invite a peer to join a channel. `key` is an
|
||||
/// optional base64 pre-shared secret; absent `key` means public.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -959,4 +1023,87 @@ mod tests {
|
||||
let decoded: BlockHeaderPayload = decode_payload(&encoded).unwrap();
|
||||
assert_eq!(decoded.height, 890412);
|
||||
}
|
||||
|
||||
const LN_PUBKEY: &str =
|
||||
"03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
|
||||
|
||||
#[test]
|
||||
fn lightning_info_round_trips_through_u8_label_and_back() {
|
||||
assert_eq!(MeshMessageType::from_u8(26), Some(MeshMessageType::LightningInfo));
|
||||
assert_eq!(MeshMessageType::LightningInfo.label(), "lightning_info");
|
||||
assert_eq!(
|
||||
MeshMessageType::from_label("lightning_info"),
|
||||
Some(MeshMessageType::LightningInfo)
|
||||
);
|
||||
// The discriminant is additive: 26 was unused before, so a deployed peer
|
||||
// that predates this simply fails to decode it rather than mis-decoding
|
||||
// it as some other type.
|
||||
assert_eq!(MeshMessageType::LightningInfo as u8, 26);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_info_payload_round_trips_over_the_wire() {
|
||||
let payload = LightningInfoPayload {
|
||||
uri: format!("{LN_PUBKEY}@1.2.3.4:9735"),
|
||||
alias: Some("archy".into()),
|
||||
};
|
||||
let encoded = encode_payload(&payload).unwrap();
|
||||
let decoded: LightningInfoPayload = decode_payload(&encoded).unwrap();
|
||||
assert_eq!(decoded.uri, payload.uri);
|
||||
assert_eq!(decoded.alias.as_deref(), Some("archy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightning_info_payload_alias_is_optional_and_omitted_on_the_wire() {
|
||||
let payload = LightningInfoPayload {
|
||||
uri: format!("{LN_PUBKEY}@1.2.3.4:9735"),
|
||||
alias: None,
|
||||
};
|
||||
let encoded = encode_payload(&payload).unwrap();
|
||||
let decoded: LightningInfoPayload = decode_payload(&encoded).unwrap();
|
||||
assert!(decoded.alias.is_none());
|
||||
// skip_serializing_if keeps the absent alias off the air entirely.
|
||||
let with_alias = encode_payload(&LightningInfoPayload {
|
||||
uri: format!("{LN_PUBKEY}@1.2.3.4:9735"),
|
||||
alias: Some("archy".into()),
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
encoded.len() < with_alias.len(),
|
||||
"an absent alias must cost fewer bytes on air, not the same"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_lightning_uris_are_accepted() {
|
||||
assert!(is_valid_lightning_uri(&format!("{LN_PUBKEY}@1.2.3.4:9735")));
|
||||
assert!(is_valid_lightning_uri(&format!("{LN_PUBKEY}@example.com:9735")));
|
||||
// The :port suffix is optional.
|
||||
assert!(is_valid_lightning_uri(&format!("{LN_PUBKEY}@1.2.3.4")));
|
||||
assert!(is_valid_lightning_uri(&format!("{LN_PUBKEY}@abcdefghij.onion:9735")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_lightning_uris_are_rejected() {
|
||||
let bad = [
|
||||
"".to_string(),
|
||||
"no-at-sign".to_string(),
|
||||
format!("{LN_PUBKEY}"), // no host at all
|
||||
format!("{LN_PUBKEY}@"), // empty host
|
||||
format!("@1.2.3.4:9735"), // no pubkey
|
||||
format!("{}@1.2.3.4:9735", &LN_PUBKEY[..65]), // pubkey too short
|
||||
format!("{}z@1.2.3.4:9735", &LN_PUBKEY[..65]), // pubkey not hex
|
||||
format!("{LN_PUBKEY}@1.2.3.4:99999"), // port out of u16 range
|
||||
format!("{LN_PUBKEY}@1.2.3.4:"), // empty port
|
||||
format!("{LN_PUBKEY}@1.2.3.4:http"), // non-numeric port
|
||||
format!("{LN_PUBKEY}@host with spaces:9735"),
|
||||
format!("{LN_PUBKEY}@a@b:9735"), // two '@'
|
||||
];
|
||||
for uri in bad {
|
||||
assert!(
|
||||
!is_valid_lightning_uri(&uri),
|
||||
"must reject malformed URI {uri:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +260,9 @@ pub(crate) async fn upsert_federation_peer(
|
||||
pkc_capable: existing.as_ref().map(|p| p.pkc_capable).unwrap_or(false),
|
||||
lat: existing.as_ref().and_then(|p| p.lat),
|
||||
lon: existing.as_ref().and_then(|p| p.lon),
|
||||
// Federation seeding carries no Lightning datum today; keep whatever an
|
||||
// explicit advert already established for this peer.
|
||||
lightning_uri: existing.as_ref().and_then(|p| p.lightning_uri.clone()),
|
||||
};
|
||||
peers.insert(contact_id, peer);
|
||||
// A radio twin of this node (same advert_name, no arch identity yet) can now
|
||||
@@ -2416,6 +2419,7 @@ mod tests {
|
||||
pkc_capable: false,
|
||||
lat: None,
|
||||
lon: None,
|
||||
lightning_uri: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,20 @@ pub struct MeshPeer {
|
||||
pub lat: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub lon: Option<f64>,
|
||||
/// This peer's advertised Lightning connection URI (`pubkey@host:port`).
|
||||
///
|
||||
/// Set ONLY from a received `LightningInfo` advertisement (or federation
|
||||
/// seeding in a later plan) — never inferred, never defaulted. `None` means
|
||||
/// "this peer has not told us it runs Lightning", which is what the
|
||||
/// channel-open picker uses to decide whether to offer it as a request
|
||||
/// target at all.
|
||||
///
|
||||
/// The advertisement is unauthenticated RF input, so this is a *request*
|
||||
/// target the operator chooses to act on, not a trusted identity. It is
|
||||
/// stored against the peer's authenticating key (`identity_pubkey_hex()`),
|
||||
/// never the firmware routing key (T-01-11).
|
||||
#[serde(default)]
|
||||
pub lightning_uri: Option<String>,
|
||||
}
|
||||
|
||||
impl MeshPeer {
|
||||
@@ -294,6 +308,7 @@ mod tests {
|
||||
pkc_capable: false,
|
||||
lat: None,
|
||||
lon: None,
|
||||
lightning_uri: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user