diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 0e475c6b..bf0b3e66 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -1901,8 +1901,23 @@ impl MeshService { // • Meshcore stock client → plain text (can't decode our envelope). // Rich typed messages (invoice/coordinate/reaction/…) always use the // typed-wire path via `send_typed_wire`; only plain Text is routed here. - let use_typed_envelope = - archy && matches!(device_type, DeviceType::Meshcore | DeviceType::Reticulum); + // A federation-synthetic contact ALWAYS takes the typed path, whatever + // radio (if any) is attached. `send_typed_wire` is the only routing + // that knows about FIPS/Tor, and it still prefers a reachable LoRa + // twin when the payload fits — so this loses no radio-first behaviour. + // + // Without this, a plain text message to a federated peer fell through + // to `peer_dest_prefix`, which resolves a RADIO routing key. On a node + // running Meshtastic — or with no radio at all — that fails, which is + // why peering a node was not enough to message it: you had to meet it + // over LoRa first so a radio twin existed to route through. Federation + // peers are reachable off-radio by definition (that is what + // `upsert_federation_peer` records with `reachable: true`), so the + // transport choice must not depend on which radio is plugged in. + let is_federation_contact = contact_id & 0x8000_0000 != 0; + let use_typed_envelope = archy + && (is_federation_contact + || matches!(device_type, DeviceType::Meshcore | DeviceType::Reticulum)); if use_typed_envelope { // Sign with our archipelago identity so the receiver can authenticate // us over LoRa (verifies against our bound `arch_pubkey_hex`). `with_seq` @@ -2360,6 +2375,50 @@ async fn bitcoin_rpc_getblockheader_by_height( #[cfg(test)] mod tests { + + /// Item 5: a federated/trusted peer must be messageable as soon as it is + /// peered — no LoRa meeting first. + /// + /// The routing predicate in `send_message` decides whether a plain text + /// message takes the federation-aware typed path (which knows FIPS/Tor and + /// still prefers a reachable radio twin) or the radio-only path, which + /// resolves an over-the-air routing key and cannot work for a peer we have + /// never heard on the radio. + /// + /// It previously keyed on the attached radio, so on a Meshtastic node — or + /// one with no radio at all — a federated peer fell to the radio path and + /// the send failed. Federation contacts are reachable off-radio by + /// definition, so the choice must not depend on which radio is plugged in. + #[test] + fn federation_contacts_take_the_off_radio_path_on_any_device() { + fn uses_typed_path(contact_id: u32, archy: bool, device: DeviceType) -> bool { + let is_federation_contact = contact_id & 0x8000_0000 != 0; + archy + && (is_federation_contact + || matches!(device, DeviceType::Meshcore | DeviceType::Reticulum)) + } + let fed = super::federation_peer_contact_id(&"ab".repeat(32)); + assert!(fed >= FEDERATION_CONTACT_ID_BASE); + + // The cases that used to fail: peered node, wrong radio or none. + for device in [ + DeviceType::Meshtastic, + DeviceType::Unknown, + DeviceType::Meshcore, + DeviceType::Reticulum, + ] { + assert!( + uses_typed_path(fed, true, device), + "federation peer must route off-radio on {device:?}" + ); + } + + // A plain radio contact on a stock-text device still takes the radio + // path — this fix must not reroute ordinary LoRa chats. + assert!(!uses_typed_path(42, true, DeviceType::Meshtastic)); + // And a stock (non-archy) client is never given a typed envelope. + assert!(!uses_typed_path(42, false, DeviceType::Meshcore)); + } use super::*; #[test]