feat(mesh): Reticulum LoRa hardware gates pass + RNS Resource transfer + image/voice attachments

Phase 0 gates #2/#3 (two-node LXMF-over-LoRa, external Sideband interop) passed
on real hardware (.116's flashed Heltec V3 RNode <-> a phone-flashed RNode running
Sideband) — RNS announce, encrypted DM round-trip, and contact binding all verified
live. Fixed two bugs found in the process: the Reticulum send path wasn't stamping
outbound messages as E2E despite LXMF being unconditionally encrypted, and the
per-message transport pill collapsed Meshcore/Meshtastic into one generic "lora"
color instead of distinguishing the three radio transports.

Built on top of that link: a Columba-style image/file send experience —
compression-quality presets with a real transfer-time estimate (mesh.transport-advice,
now device-throughput-aware), receive-side thumbnail previews + auto-render for
already-local attachments, and async voice messages, all reusing the existing
ContentRef/ContentInline attachment pipeline. The headline addition is genuine RNS
Resource transfer support (daemon-side RNS.Link + RNS.Resource, Rust-side
send_resource/resource_recv plumbing, a new "resource-mesh" transport-advice tier)
so compressed photos up to 2MB now actually transfer over LoRa for Reticulum peers
instead of always falling back to Tor past the small inline-chunk cap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-30 19:57:01 -04:00
co-authored by Claude Sonnet 5
parent 12e7990b10
commit f54c853128
21 changed files with 2696 additions and 114 deletions
@@ -391,9 +391,24 @@ impl RpcHandler {
// Hard ceiling matching the chunked-send capacity (~20 chunks * 152
// b64 chars after MCIIXXTT framing). Anything larger must go via
// ContentRef over Tor.
// ContentRef over Tor — UNLESS the active device is Reticulum, which
// can carry up to RETICULUM_RESOURCE_MAX directly over LoRa via a
// native RNS Resource transfer (keep this ceiling in sync with
// `mesh.transport-advice`'s `"resource-mesh"` tier, the source of
// truth the frontend consults before ever reaching this size).
const INLINE_HARD_MAX: usize = 2300;
if bytes.len() > INLINE_HARD_MAX {
const RETICULUM_RESOURCE_MAX: usize = 2 * 1024 * 1024;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let device_type = svc.shared_state().status.read().await.device_type;
let use_resource_transfer = bytes.len() > INLINE_HARD_MAX
&& device_type == crate::mesh::types::DeviceType::Reticulum
&& bytes.len() <= RETICULUM_RESOURCE_MAX;
if bytes.len() > INLINE_HARD_MAX && !use_resource_transfer {
anyhow::bail!(
"Payload {} bytes exceeds inline max {} — use mesh.send-content (ContentRef) instead",
bytes.len(),
@@ -414,11 +429,6 @@ impl RpcHandler {
.put(&bytes, &mime, filename.clone(), None, false)
.await?;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let content = ContentInlinePayload {
mime: mime.clone(),
filename: filename.clone(),
@@ -447,8 +457,8 @@ impl RpcHandler {
"inline": true,
});
let msg = svc
.send_typed_wire(
let msg = if use_resource_transfer {
svc.send_content_resource(
contact_id,
wire,
"content_ref",
@@ -456,12 +466,24 @@ impl RpcHandler {
Some(typed_json),
seq,
)
.await?;
.await?
} else {
svc.send_typed_wire(
contact_id,
wire,
"content_ref",
&display,
Some(typed_json),
seq,
)
.await?
};
info!(
contact_id,
size = meta.size,
cid = %meta.cid,
via_resource = use_resource_transfer,
"Sent content_inline over mesh"
);
Ok(serde_json::json!({
@@ -492,8 +514,19 @@ impl RpcHandler {
// Knobs — keep in sync with the frontend modal copy.
const MESH_AUTO_MAX: u64 = 1024;
const MESH_HARD_MAX: u64 = 2300;
// Reticulum-only: above the small inline-chunk cap, a real RNS Resource
// transfer can still carry the payload directly over LoRa (native
// chunked transfer with retries) instead of falling back to Tor. Capped
// well under TOR_LARGE_WARN to keep worst-case LoRa transfer time
// bounded — comfortably covers the HIGH image preset (512KB target).
const RETICULUM_RESOURCE_MAX: u64 = 2 * 1024 * 1024;
const TOR_LARGE_WARN: u64 = 5 * 1024 * 1024;
const LORA_BYTES_PER_SEC: u64 = 50;
// Meshcore/Meshtastic effective LoRa throughput after retries/FEC is much
// lower than the raw radio bitrate. Reticulum's RNodeInterface reports its
// real bitrate (e.g. ~3125 bps ≈ 390 B/s observed live), so estimates for it
// would be wildly pessimistic at the generic 50 B/s figure.
const LORA_BYTES_PER_SEC_DEFAULT: u64 = 50;
const LORA_BYTES_PER_SEC_RETICULUM: u64 = 390;
// Resolve peer Tor reachability via federation node list.
let service = self.mesh_service.read().await;
@@ -501,6 +534,12 @@ impl RpcHandler {
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let state = svc.shared_state();
let device_type = state.status.read().await.device_type;
let lora_bytes_per_sec = if device_type == crate::mesh::types::DeviceType::Reticulum {
LORA_BYTES_PER_SEC_RETICULUM
} else {
LORA_BYTES_PER_SEC_DEFAULT
};
let (peer_pubkey_hex, peer_did) = {
let peers = state.peers.read().await;
match peers.get(&contact_id) {
@@ -520,8 +559,10 @@ impl RpcHandler {
.map(|d| nodes.iter().any(|n| &n.did == d))
.unwrap_or(false);
let est_seconds = (size.saturating_add(LORA_BYTES_PER_SEC - 1) / LORA_BYTES_PER_SEC).max(1);
let est_seconds =
(size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
let is_reticulum = device_type == crate::mesh::types::DeviceType::Reticulum;
let (tier, reason) = if size <= MESH_AUTO_MAX {
("auto-mesh", "Small enough to send inline over mesh")
} else if size <= MESH_HARD_MAX {
@@ -530,6 +571,8 @@ impl RpcHandler {
} else {
("auto-mesh", "No Tor path — sending inline over mesh")
}
} else if is_reticulum && size <= RETICULUM_RESOURCE_MAX {
("resource-mesh", "Sending directly over LoRa via a Reticulum resource transfer")
} else if size <= TOR_LARGE_WARN {
if has_tor {
("tor-only", "Too large for mesh — Tor only")
@@ -674,18 +717,6 @@ impl RpcHandler {
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing cid"))?
.to_string();
let sender_onion = params["sender_onion"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing sender_onion"))?
.trim_end_matches('/')
.to_string();
let cap_token = params["cap_token"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing cap_token"))?
.to_string();
let cap_exp = params["cap_exp"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing cap_exp"))?;
let mime_hint = params["mime"]
.as_str()
.unwrap_or("application/octet-stream")
@@ -709,7 +740,12 @@ impl RpcHandler {
};
// Short-circuit if we already hold the blob — still issue a fresh
// self-cap so the UI gets a displayable local URL.
// self-cap so the UI gets a displayable local URL. Checked BEFORE the
// sender_onion/cap_token/cap_exp params are required below: an inline
// ContentInline attachment (mesh.send-content-inline) is written to
// our own BlobStore the moment it's received/sent (dispatch.rs), so
// its typed_payload never carries those fields at all — only a
// ContentRef fetched from a remote peer needs them.
if blob_store.has(&cid).await {
let local_exp = (chrono::Utc::now().timestamp() as u64) + DEFAULT_CAP_TTL_SECS;
let local_cap = blob_store.issue_capability(&cid, &self_pubkey_hex, local_exp);
@@ -725,6 +761,19 @@ impl RpcHandler {
}));
}
let sender_onion = params["sender_onion"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing sender_onion"))?
.trim_end_matches('/')
.to_string();
let cap_token = params["cap_token"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing cap_token"))?
.to_string();
let cap_exp = params["cap_exp"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing cap_exp"))?;
// Reach the sender: FIPS preferred when the sender is federated
// and has advertised a FIPS npub, Tor fallback otherwise.
// Cap/exp/peer in the query string match what the sender signed in