refactor: split package.rs, mod.rs, listener.rs, and lnd.rs into focused submodules

- R35: Split package.rs (1794 lines) into package/{mod,config,validation,lifecycle}.rs
- R36: Split mesh/listener.rs (1799 lines) into listener/{mod,session,frames,decode,dispatch,bitcoin}.rs
- R37: Split rpc/mod.rs into mod.rs + dispatcher.rs, middleware.rs, response.rs (54% reduction)
- R38: Split lnd.rs (1064 lines) into lnd/{mod,info,channels,wallet,payments}.rs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-21 02:26:28 +00:00
co-authored by Claude Opus 4.6
parent 8e4d352393
commit 77f550fb5e
21 changed files with 4780 additions and 4374 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,374 @@
//! Bitcoin relay operations: TX broadcast, confirmation tracking, peer messaging.
use super::MeshCommand;
use super::MeshState;
use super::super::crypto;
use super::super::message_types;
use std::sync::Arc;
use std::time::Duration;
use tracing::{debug, info, warn};
/// Called on an internet-connected node when it receives a TxRelay request.
/// Broadcasts the raw TX to Bitcoin via RPC, sends the txid back, then
/// monitors for 3 confirmations and sends updates back via mesh.
pub(super) async fn handle_tx_relay_broadcast(
relay: message_types::TxRelayPayload,
sender_contact_id: u32,
state: &Arc<MeshState>,
) {
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(e) => {
warn!("Failed to create HTTP client for TX relay: {}", e);
return;
}
};
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
// Pre-flight: check if Bitcoin Core is reachable and synced
if !preflight_check(&client, &rpc_user, &rpc_pass, &relay, sender_contact_id, state).await {
return;
}
// Step 1: Broadcast via Bitcoin Core RPC sendrawtransaction
let txid = match broadcast_transaction(&client, &rpc_user, &rpc_pass, &relay, sender_contact_id, state).await {
Some(id) => id,
None => return,
};
info!(request_id = relay.request_id, txid = %txid, "TX broadcast successful — tracking confirmations");
// Step 2: Send TxRelayResponse with txid back to originator
send_tx_relay_response(state, sender_contact_id, relay.request_id, Some(&txid), None, None).await;
// Step 3: Monitor confirmations (poll every 30s, up to 3 hours)
track_confirmations(&client, &txid, relay.request_id, sender_contact_id, state).await;
}
/// Pre-flight check: verify Bitcoin Core is reachable and synced.
/// Returns `true` if the node is ready, `false` if an error response was sent.
async fn preflight_check(
client: &reqwest::Client,
rpc_user: &str,
rpc_pass: &str,
relay: &message_types::TxRelayPayload,
sender_contact_id: u32,
state: &Arc<MeshState>,
) -> bool {
let preflight_body = serde_json::json!({
"jsonrpc": "1.0",
"id": "preflight",
"method": "getblockchaininfo",
"params": []
});
match client
.post(crate::constants::BITCOIN_RPC_URL)
.basic_auth(rpc_user, Some(rpc_pass))
.json(&preflight_body)
.send()
.await
{
Ok(resp) => {
if let Ok(rpc_resp) = resp.json::<serde_json::Value>().await {
if let Some(result) = rpc_resp.get("result") {
let ibd = result.get("initialblockdownload")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let progress = result.get("verificationprogress")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
if ibd || progress < 0.999 {
let pct = (progress * 100.0) as u32;
let msg = format!("Bitcoin node is syncing ({}%) — cannot broadcast yet", pct);
warn!(request_id = relay.request_id, "{}", msg);
send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some(&msg), Some("bitcoin_syncing")).await;
return false;
}
} else if let Some(err) = rpc_resp.get("error").and_then(|e| e.as_object()) {
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("RPC error");
warn!(request_id = relay.request_id, "Bitcoin pre-flight failed: {}", msg);
send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some(&format!("Bitcoin node error: {}", msg)), Some("bitcoin_unreachable")).await;
return false;
}
}
}
Err(e) => {
let msg = format!("Bitcoin node unreachable — {}", if e.is_connect() {
"connection refused (node may be stopped)"
} else if e.is_timeout() {
"connection timed out"
} else {
"network error"
});
warn!(request_id = relay.request_id, "Pre-flight: {}: {}", msg, e);
send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some(&msg), Some("bitcoin_unreachable")).await;
return false;
}
}
true
}
/// Broadcast a raw transaction via Bitcoin Core RPC.
/// Returns the txid on success, or None if an error response was sent.
async fn broadcast_transaction(
client: &reqwest::Client,
rpc_user: &str,
rpc_pass: &str,
relay: &message_types::TxRelayPayload,
sender_contact_id: u32,
state: &Arc<MeshState>,
) -> Option<String> {
let body = serde_json::json!({
"jsonrpc": "1.0",
"id": "mesh-relay",
"method": "sendrawtransaction",
"params": [relay.tx_hex]
});
let txid = match client
.post(crate::constants::BITCOIN_RPC_URL)
.basic_auth(rpc_user, Some(rpc_pass))
.json(&body)
.send()
.await
{
Ok(resp) => {
match resp.json::<serde_json::Value>().await {
Ok(rpc_resp) => {
if let Some(err) = rpc_resp.get("error").and_then(|e| e.as_object()) {
let code = err.get("code").and_then(|c| c.as_i64()).unwrap_or(0);
let msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("unknown");
let user_msg = match code {
-25 => format!("TX already in mempool or confirmed: {}", msg),
-26 => format!("TX rejected by mempool policy: {}", msg),
-27 => format!("TX already confirmed in a block"),
_ => format!("Bitcoin rejected TX (code {}): {}", code, msg),
};
warn!(request_id = relay.request_id, rpc_code = code, "sendrawtransaction: {}", msg);
send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some(&user_msg), Some(&format!("tx_rejected:{}", code))).await;
return None;
}
rpc_resp.get("result").and_then(|r| r.as_str()).map(|s| s.to_string())
}
Err(e) => {
warn!("Failed to parse Bitcoin RPC response: {}", e);
send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some("Failed to parse Bitcoin node response"), Some("rpc_parse_error")).await;
return None;
}
}
}
Err(e) => {
let msg = format!("Bitcoin node unreachable during broadcast — {}", if e.is_connect() {
"connection refused"
} else if e.is_timeout() {
"timed out"
} else {
"network error"
});
warn!("Bitcoin Core RPC unreachable: {}", e);
send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some(&msg), Some("bitcoin_unreachable")).await;
return None;
}
};
if txid.is_none() {
send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some("Bitcoin node returned no transaction ID"), Some("rpc_parse_error")).await;
}
txid
}
/// Monitor a transaction for confirmations (poll every 30s, up to 3 hours).
async fn track_confirmations(
client: &reqwest::Client,
txid: &str,
request_id: u64,
sender_contact_id: u32,
state: &Arc<MeshState>,
) {
let mut last_reported_confs: u32 = 0;
for _ in 0..360 {
tokio::time::sleep(Duration::from_secs(30)).await;
match check_tx_confirmations(client, txid).await {
Ok((confs, block_height)) => {
if confs > last_reported_confs && confs <= 3 {
info!(txid = %txid, confirmations = confs, "Sending confirmation update via mesh");
send_confirmation_update(state, sender_contact_id, request_id, txid, confs, block_height).await;
last_reported_confs = confs;
if confs >= 3 {
info!(txid = %txid, "TX fully confirmed (3/3) — done tracking");
return;
}
}
}
Err(e) => {
debug!(txid = %txid, "Confirmation check: {}", e);
}
}
}
}
/// Send a TxRelayResponse back to the originating peer.
async fn send_tx_relay_response(
state: &Arc<MeshState>,
dest_contact_id: u32,
request_id: u64,
txid: Option<&str>,
error: Option<&str>,
error_code: Option<&str>,
) {
let wire = match super::super::bitcoin_relay::build_tx_relay_response(request_id, txid, error, error_code) {
Ok(w) => w,
Err(e) => {
warn!("Failed to build TX relay response: {}", e);
return;
}
};
send_to_peer(state, dest_contact_id, wire).await;
}
/// Send a TxConfirmation update to the originator.
async fn send_confirmation_update(
state: &Arc<MeshState>,
dest_contact_id: u32,
request_id: u64,
txid: &str,
confirmations: u32,
block_height: u64,
) {
let conf = message_types::TxConfirmationPayload {
request_id,
txid: txid.to_string(),
confirmations,
block_height,
};
if let Ok(payload_bytes) = message_types::encode_payload(&conf) {
let envelope = message_types::TypedEnvelope::new(
message_types::MeshMessageType::TxConfirmation,
payload_bytes,
);
if let Ok(wire) = envelope.to_wire() {
send_to_peer(state, dest_contact_id, wire).await;
}
}
}
/// Encrypt a typed wire payload for a specific peer.
/// Attempts ratchet encryption first (forward secrecy), falls back to static
/// shared secret, falls back to plaintext if neither is available.
/// Respects the encrypt_relay config toggle for rollback.
async fn encrypt_for_peer(
state: &Arc<MeshState>,
contact_id: u32,
typed_wire: &[u8],
) -> Vec<u8> {
if !state.encrypt_relay {
return typed_wire.to_vec();
}
// Look up peer DID for ratchet session
let peer_did = state.peers.read().await
.get(&contact_id)
.and_then(|p| p.did.clone());
// Try ratchet encryption first (forward secrecy)
if let Some(ref did) = peer_did {
if state.session_manager.has_session(did).await {
match state.session_manager.encrypt_for_peer(did, typed_wire).await {
Ok(ratchet_msg) => {
let ratchet_bytes = ratchet_msg.to_bytes();
let mut buf = Vec::with_capacity(1 + ratchet_bytes.len());
buf.push(message_types::RATCHET_TYPED_MARKER);
buf.extend_from_slice(&ratchet_bytes);
debug!(contact_id, did = %did, "Encrypted with Double Ratchet (0xDD)");
return buf;
}
Err(e) => {
warn!(contact_id, did = %did, "Ratchet encrypt failed, trying static: {}", e);
}
}
}
}
// Fall back to static shared secret (0xEE)
let secrets = state.shared_secrets.read().await;
if let Some(secret) = secrets.get(&contact_id) {
match crypto::encrypt(secret, typed_wire) {
Ok(ciphertext) => {
let mut buf = Vec::with_capacity(1 + ciphertext.len());
buf.push(message_types::ENCRYPTED_TYPED_MARKER);
buf.extend_from_slice(&ciphertext);
debug!(contact_id, "Encrypted with static shared secret (0xEE)");
return buf;
}
Err(e) => {
warn!(contact_id, "Static encrypt failed, sending plaintext: {}", e);
}
}
}
// No encryption available — send plaintext
debug!(contact_id, "No encryption available, sending plaintext (0x02)");
typed_wire.to_vec()
}
/// Send raw wire bytes to a specific peer by contact_id.
/// Encrypts directed messages via ratchet or shared secret when available.
/// Falls back to channel 0 broadcast (plaintext) if peer's pubkey is unknown.
async fn send_to_peer(state: &Arc<MeshState>, contact_id: u32, typed_wire: Vec<u8>) {
let peers = state.peers.read().await;
if let Some(peer) = peers.get(&contact_id) {
if let Some(ref pk) = peer.pubkey_hex {
if let Ok(pk_bytes) = hex::decode(pk) {
if pk_bytes.len() >= 6 {
let mut prefix = [0u8; 6];
prefix.copy_from_slice(&pk_bytes[..6]);
drop(peers);
// Encrypt for this specific peer before sending
let payload = encrypt_for_peer(state, contact_id, &typed_wire).await;
let _ = state.cmd_tx.send(MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload,
}).await;
return;
}
}
}
}
drop(peers);
// Broadcast fallback — plaintext (no specific peer to encrypt for)
let _ = state.cmd_tx.send(MeshCommand::BroadcastChannel {
channel: 0,
payload: typed_wire,
}).await;
}
/// Check transaction confirmation count via Bitcoin Core RPC.
async fn check_tx_confirmations(client: &reqwest::Client, txid: &str) -> anyhow::Result<(u32, u64)> {
let body = serde_json::json!({
"jsonrpc": "1.0",
"id": "mesh-conf",
"method": "gettransaction",
"params": [txid]
});
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
let resp = client
.post(crate::constants::BITCOIN_RPC_URL)
.basic_auth(&rpc_user, Some(&rpc_pass))
.json(&body)
.send()
.await?;
let rpc_resp: serde_json::Value = resp.json().await?;
if let Some(result) = rpc_resp.get("result") {
let confs = result.get("confirmations").and_then(|c| c.as_u64()).unwrap_or(0) as u32;
let block_height = result.get("blockheight").and_then(|h| h.as_u64()).unwrap_or(0);
Ok((confs, block_height))
} else {
anyhow::bail!("gettransaction returned no result")
}
}
@@ -0,0 +1,457 @@
//! Message decoding: base64, encryption, chunk reassembly, peer resolution.
use super::MeshState;
use super::super::crypto;
use super::super::message_types::{self, TypedEnvelope};
use super::super::types::*;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{debug, info, warn};
/// Try to base64-decode payload and check if the result is a typed envelope.
/// Handles: plain typed (0x02), steganographic (0xAA), and encrypted (0xEE).
/// Returns the decoded bytes if it's a valid base64-encoded TypedEnvelope.
pub(super) fn try_base64_typed(payload: &[u8]) -> Option<Vec<u8>> {
use base64::Engine;
if payload.is_empty() || payload[0] == message_types::TYPED_MESSAGE_MARKER {
return None;
}
let text = std::str::from_utf8(payload).ok()?;
let decoded = base64::engine::general_purpose::STANDARD.decode(text.trim()).ok()?;
unwrap_wire_layers(&decoded)
}
/// Try to base64-decode and decrypt an encrypted typed message.
/// Handles the common case where encrypted messages arrive as base64 text.
pub(super) async fn try_decrypt_base64(
payload: &[u8],
sender_contact_id: u32,
state: &Arc<MeshState>,
) -> Option<Vec<u8>> {
use base64::Engine;
let text = std::str::from_utf8(payload).ok()?;
let decoded = base64::engine::general_purpose::STANDARD.decode(text.trim()).ok()?;
if decoded.first() != Some(&message_types::ENCRYPTED_TYPED_MARKER) {
return None;
}
let secrets = state.shared_secrets.read().await;
try_decrypt_typed(&decoded, sender_contact_id, &secrets)
}
/// Try to decrypt a Double Ratchet encrypted message (0xDD prefix).
/// Format: [0xDD] [RatchetHeader(40) + nonce(12) + ciphertext + tag(16)]
/// Returns the decrypted typed wire bytes ([0x02][CBOR]) if successful.
async fn try_decrypt_ratchet(
decoded: &[u8],
sender_contact_id: u32,
state: &Arc<MeshState>,
) -> Option<Vec<u8>> {
if decoded.first() != Some(&message_types::RATCHET_TYPED_MARKER) {
return None;
}
let ratchet_bytes = &decoded[1..]; // skip 0xDD marker
let ratchet_msg = match super::super::ratchet::RatchetMessage::from_bytes(ratchet_bytes) {
Ok(msg) => msg,
Err(e) => {
warn!(contact_id = sender_contact_id, "Failed to parse ratchet message: {}", e);
return None;
}
};
// Look up peer DID for session manager
let peer_did = state.peers.read().await
.get(&sender_contact_id)
.and_then(|p| p.did.clone())?;
match state.session_manager.decrypt_from_peer(&peer_did, &ratchet_msg).await {
Ok(plaintext) => {
debug!(contact_id = sender_contact_id, did = %peer_did, "Decrypted ratchet message (0xDD)");
// The plaintext should be the original [0x02][CBOR] typed wire
if TypedEnvelope::is_typed(&plaintext) {
Some(plaintext)
} else {
// Could be nested stego -> typed
unwrap_wire_layers(&plaintext)
}
}
Err(e) => {
warn!(contact_id = sender_contact_id, "Ratchet decrypt failed: {}", e);
None
}
}
}
/// Try to base64-decode and decrypt a ratchet-encrypted message.
/// Handles the case where ratchet messages arrive as base64 text.
pub(super) async fn try_decrypt_ratchet_base64(
payload: &[u8],
sender_contact_id: u32,
state: &Arc<MeshState>,
) -> Option<Vec<u8>> {
use base64::Engine;
let text = std::str::from_utf8(payload).ok()?;
let decoded = base64::engine::general_purpose::STANDARD.decode(text.trim()).ok()?;
if decoded.first() != Some(&message_types::RATCHET_TYPED_MARKER) {
return None;
}
try_decrypt_ratchet(&decoded, sender_contact_id, state).await
}
/// Unwrap wire layers: encrypted (0xEE) -> stego (0xAA) -> typed (0x02).
/// Returns None if decoding fails at any layer (caller should use shared_secrets variant).
fn unwrap_wire_layers(decoded: &[u8]) -> Option<Vec<u8>> {
// Check for steganographic frame (0xAA prefix) — unwrap to typed envelope
if decoded.first() == Some(&super::super::steganography::STEGO_MARKER) {
match super::super::steganography::decode_typed_wire(decoded) {
Ok(typed_wire) => return Some(typed_wire),
Err(_) => return None,
}
}
if TypedEnvelope::is_typed(decoded) {
Some(decoded.to_vec())
} else {
None
}
}
/// Try to decrypt an encrypted typed message (0xEE prefix) using known shared secrets.
/// Format: [0xEE] [nonce: 12] [ciphertext + tag: 16]
fn try_decrypt_typed(
decoded: &[u8],
sender_contact_id: u32,
shared_secrets: &HashMap<u32, [u8; 32]>,
) -> Option<Vec<u8>> {
if decoded.first() != Some(&message_types::ENCRYPTED_TYPED_MARKER) {
return None;
}
let ciphertext = &decoded[1..]; // skip 0xEE marker
// Try sender's shared secret first (most likely)
if let Some(secret) = shared_secrets.get(&sender_contact_id) {
if let Ok(plaintext) = crypto::decrypt(secret, ciphertext) {
return unwrap_wire_layers(&plaintext);
}
}
// Fallback: try all known shared secrets (in case contact_id mapping is stale)
for (cid, secret) in shared_secrets {
if *cid == sender_contact_id { continue; } // already tried
if let Ok(plaintext) = crypto::decrypt(secret, ciphertext) {
return unwrap_wire_layers(&plaintext);
}
}
None
}
/// Check if payload is a mesh chunk ("MC" prefix) and try to reassemble.
/// Format: MC{msg_id:2hex}{chunk_idx:2hex}{total:2hex}{base64_data}
/// Returns Some(decoded_bytes) when all chunks have arrived.
pub(super) async fn try_chunk_reassemble(
payload: &[u8],
sender_contact_id: u32,
state: &Arc<MeshState>,
) -> Option<Vec<u8>> {
use base64::Engine;
let text = std::str::from_utf8(payload).ok()?;
if !text.starts_with("MC") || text.len() < 8 {
return None;
}
let msg_id = u8::from_str_radix(&text[2..4], 16).ok()?;
let chunk_idx = u8::from_str_radix(&text[4..6], 16).ok()?;
let total = u8::from_str_radix(&text[6..8], 16).ok()?;
let chunk_data = &text[8..];
if total == 0 || total > 20 {
return None; // sanity check
}
let key = (sender_contact_id, msg_id);
let mut buffer = state.chunk_buffer.write().await;
// Clean up stale entries (>120s old)
buffer.retain(|_, v| v.created.elapsed().as_secs() < 120);
let assembly = buffer.entry(key).or_insert_with(|| super::ChunkAssembly {
chunks: HashMap::new(),
total,
created: std::time::Instant::now(),
});
assembly.chunks.insert(chunk_idx, chunk_data.to_string());
assembly.total = total; // update in case first chunk had it wrong
debug!(msg_id, chunk_idx, total, received = assembly.chunks.len(), "Chunk received");
// Check if we have all chunks
if assembly.chunks.len() < total as usize {
return None;
}
// All chunks received — reassemble in order
let mut combined = String::new();
for i in 0..total {
match assembly.chunks.get(&i) {
Some(data) => combined.push_str(data),
None => {
warn!(msg_id, missing = i, "Chunk missing during reassembly");
return None;
}
}
}
if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(&combined) {
// Check for ratchet-encrypted frame (0xDD) — decrypt then unwrap
if decoded.first() == Some(&message_types::RATCHET_TYPED_MARKER) {
// Must drop buffer lock before calling async try_decrypt_ratchet
let decoded_clone = decoded.clone();
drop(buffer);
if let Some(typed_wire) = try_decrypt_ratchet(&decoded_clone, sender_contact_id, state).await {
info!(msg_id, chunks = total, total_len = typed_wire.len(), "Reassembled ratchet-encrypted chunked message");
state.chunk_buffer.write().await.remove(&key);
return Some(typed_wire);
}
buffer = state.chunk_buffer.write().await;
}
// Check for static-encrypted frame (0xEE) — decrypt then unwrap
if decoded.first() == Some(&message_types::ENCRYPTED_TYPED_MARKER) {
let secrets = state.shared_secrets.read().await;
if let Some(typed_wire) = try_decrypt_typed(&decoded, sender_contact_id, &secrets) {
info!(msg_id, chunks = total, total_len = typed_wire.len(), "Reassembled encrypted chunked message");
buffer.remove(&key);
return Some(typed_wire);
}
}
// Check for stego frame — unwrap to typed envelope
if decoded.first() == Some(&super::super::steganography::STEGO_MARKER) {
if let Ok(typed_wire) = super::super::steganography::decode_typed_wire(&decoded) {
info!(msg_id, chunks = total, total_len = typed_wire.len(), "Reassembled stego chunked message");
buffer.remove(&key);
return Some(typed_wire);
}
}
if TypedEnvelope::is_typed(&decoded) {
info!(msg_id, chunks = total, total_len = decoded.len(), "Reassembled chunked message");
buffer.remove(&key);
return Some(decoded);
}
}
warn!(msg_id, "All chunks received but decode failed");
buffer.remove(&key);
None
}
/// Look up a peer by pubkey hex prefix. Returns (contact_id, display_name).
pub(super) async fn resolve_peer(state: &Arc<MeshState>, sender_prefix: &str) -> (u32, String) {
let peers = state.peers.read().await;
peers
.values()
.find(|p| {
p.pubkey_hex
.as_ref()
.map(|k| k.starts_with(sender_prefix))
.unwrap_or(false)
})
.map(|p| (p.contact_id, p.advert_name.clone()))
.unwrap_or((0, sender_prefix.to_string()))
}
/// Store a plain-text (non-typed) message and emit an event.
pub(super) async fn store_plain_message(
state: &Arc<MeshState>,
contact_id: u32,
peer_name: &str,
text: &str,
) {
let msg_id = state.next_id().await;
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Received,
peer_contact_id: contact_id,
peer_name: Some(peer_name.to_string()),
plaintext: text.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted: false,
};
state.store_message(msg.clone()).await;
state.status.write().await.messages_received += 1;
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
}
/// Handle a received identity broadcast from a peer.
#[allow(dead_code)]
pub(super) async fn handle_identity_received(
contact_id: u32,
rssi: i16,
did: &str,
ed_pubkey_hex: &str,
x25519_pubkey_hex: &str,
state: &Arc<MeshState>,
our_x25519_secret: &[u8; 32],
) {
info!(
contact_id,
did = %did,
rssi,
"Archipelago peer discovered over mesh"
);
// Verify Ed25519 public key is valid
let ed_pubkey_bytes = match hex::decode(ed_pubkey_hex) {
Ok(b) if b.len() == 32 => {
let mut arr = [0u8; 32];
arr.copy_from_slice(&b);
arr
}
_ => {
warn!(contact_id, "Rejecting identity: invalid Ed25519 public key");
return;
}
};
if ed25519_dalek::VerifyingKey::from_bytes(&ed_pubkey_bytes).is_err() {
warn!(contact_id, "Rejecting identity: Ed25519 key is not a valid curve point");
return;
}
// Verify X25519 public key is consistent with Ed25519 key
let expected_x25519 = match crypto::ed25519_pubkey_to_x25519(&ed_pubkey_bytes) {
Ok(k) => k,
Err(e) => {
warn!(contact_id, "Rejecting identity: cannot derive X25519 from Ed25519: {}", e);
return;
}
};
// Decode X25519 public key
let x25519_bytes = match hex::decode(x25519_pubkey_hex) {
Ok(b) if b.len() == 32 => {
let mut arr = [0u8; 32];
arr.copy_from_slice(&b);
arr
}
_ => {
warn!(contact_id, "Rejecting identity: invalid X25519 public key");
return;
}
};
if x25519_bytes != expected_x25519 {
warn!(contact_id, did = %did, "Rejecting identity: X25519 key does not match Ed25519 key");
return;
}
// Derive shared secret for encrypted messaging
let shared_secret = crypto::x25519_shared_secret(our_x25519_secret, &x25519_bytes);
state
.shared_secrets
.write()
.await
.insert(contact_id, shared_secret);
// Update peer record
let peer = MeshPeer {
contact_id,
advert_name: format!("Archy-{}", &did[8..16.min(did.len())]),
did: Some(did.to_string()),
pubkey_hex: Some(ed_pubkey_hex.to_string()),
x25519_pubkey: Some(x25519_bytes),
rssi: Some(rssi),
snr: None,
last_heard: chrono::Utc::now().to_rfc3339(),
hops: 0,
};
let is_new = {
let mut peers = state.peers.write().await;
let is_new = !peers.contains_key(&contact_id);
peers.insert(contact_id, peer.clone());
is_new
};
state.update_peer_count().await;
let event = if is_new {
MeshEvent::PeerDiscovered(peer)
} else {
MeshEvent::PeerUpdated(peer)
};
let _ = state.event_tx.send(event);
let _ = state.event_tx.send(MeshEvent::IdentityReceived {
contact_id,
did: did.to_string(),
pubkey_hex: ed_pubkey_hex.to_string(),
x25519_pubkey: x25519_bytes,
});
}
/// Handle a received message (direct or channel).
#[allow(dead_code)]
pub(super) async fn handle_received_message(
contact_id: u32,
payload: &[u8],
rssi: i16,
is_channel: bool,
state: &Arc<MeshState>,
_our_x25519_secret: &[u8; 32],
) {
// Try to decrypt if we have a shared secret for this contact
let shared_secrets = state.shared_secrets.read().await;
let (plaintext, encrypted) = if let Some(secret) = shared_secrets.get(&contact_id) {
match crypto::decrypt(secret, payload) {
Ok(pt) => (String::from_utf8_lossy(&pt).to_string(), true),
Err(_) => {
// Not encrypted or wrong key — treat as plaintext
(String::from_utf8_lossy(payload).to_string(), false)
}
}
} else {
(String::from_utf8_lossy(payload).to_string(), false)
};
drop(shared_secrets);
// Update peer last_heard
{
let mut peers = state.peers.write().await;
if let Some(peer) = peers.get_mut(&contact_id) {
peer.last_heard = chrono::Utc::now().to_rfc3339();
peer.rssi = Some(rssi);
}
}
let peer_name = state
.peers
.read()
.await
.get(&contact_id)
.map(|p| p.advert_name.clone());
let msg_id = state.next_id().await;
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Received,
peer_contact_id: contact_id,
peer_name,
plaintext: plaintext.clone(),
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted,
};
state.store_message(msg.clone()).await;
{
let mut status = state.status.write().await;
status.messages_received += 1;
}
info!(
contact_id,
encrypted,
channel = is_channel,
"Received mesh message"
);
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
}
@@ -0,0 +1,402 @@
//! Typed message dispatch — routes TypedEnvelope messages to type-specific handlers.
use super::bitcoin::handle_tx_relay_broadcast;
use super::decode::store_plain_message;
use super::MeshState;
use super::super::message_types::{self, MeshMessageType, TypedEnvelope};
use super::super::types::*;
use std::sync::Arc;
use tracing::{debug, info, warn};
/// Store a typed message with a type label for UI rendering.
async fn store_typed_message(
state: &Arc<MeshState>,
contact_id: u32,
peer_name: &str,
text: &str,
type_label: &str,
) {
let msg_id = state.next_id().await;
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Received,
peer_contact_id: contact_id,
peer_name: Some(peer_name.to_string()),
plaintext: format!("[{}] {}", type_label, text),
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted: false,
};
state.store_message(msg.clone()).await;
state.status.write().await.messages_received += 1;
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
}
/// Handle a typed message envelope (0x02 prefix).
/// Dispatches to type-specific handlers: BlockHeader, Alert, TxRelay, etc.
pub(super) async fn handle_typed_message(
payload: &[u8],
sender_contact_id: u32,
sender_name: &str,
state: &Arc<MeshState>,
) {
let envelope = match TypedEnvelope::from_wire(payload) {
Ok(e) => e,
Err(e) => {
warn!(
payload_len = payload.len(),
first_bytes = %hex::encode(&payload[..payload.len().min(16)]),
"Failed to decode typed envelope: {}", e
);
return;
}
};
// Verify envelope signature if present, using the sender's known Ed25519 key
if envelope.sig.is_some() {
let peer_pubkey = state.peers.read().await
.get(&sender_contact_id)
.and_then(|p| p.pubkey_hex.as_ref())
.and_then(|hex_str| hex::decode(hex_str).ok())
.and_then(|bytes| {
if bytes.len() == 32 {
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
ed25519_dalek::VerifyingKey::from_bytes(&arr).ok()
} else {
None
}
});
if let Some(vk) = peer_pubkey {
match envelope.verify_signature(&vk) {
Ok(true) => {}
Ok(false) => {
warn!(peer = sender_contact_id, "Dropping message with invalid signature");
return;
}
Err(e) => {
warn!(peer = sender_contact_id, "Signature verification error: {}", e);
return;
}
}
}
}
let msg_type = envelope.message_type();
let type_label = msg_type.map(|t| t.label()).unwrap_or("unknown");
info!(
msg_type = type_label,
from = sender_contact_id,
"Received typed mesh message"
);
match msg_type {
Some(MeshMessageType::BlockHeader) => {
dispatch_block_header(&envelope, sender_contact_id, sender_name, state).await;
}
Some(MeshMessageType::Alert) => {
match message_types::decode_payload::<message_types::AlertPayload>(&envelope.v) {
Ok(alert) => {
let alert_type_str = format!("{:?}", alert.alert_type).to_lowercase();
info!(
alert_type = %alert_type_str,
from = sender_contact_id,
"Alert received via mesh: {}",
alert.message
);
store_typed_message(
state,
sender_contact_id,
sender_name,
&alert.message,
"alert",
)
.await;
let _ = state.event_tx.send(MeshEvent::AlertReceived {
alert_type: alert_type_str,
message: alert.message,
from_contact_id: sender_contact_id,
});
}
Err(e) => warn!("Failed to decode alert payload: {}", e),
}
}
Some(MeshMessageType::TxRelay) => {
match message_types::decode_payload::<message_types::TxRelayPayload>(&envelope.v) {
Ok(relay) => {
// Validate transaction before relaying
if !super::super::bitcoin_relay::validate_raw_transaction(&relay.tx_hex) {
warn!(peer = sender_contact_id, "Rejected invalid TX relay");
return;
}
info!(
request_id = relay.request_id,
tx_len = relay.tx_hex.len(),
"TX relay request received — broadcasting to Bitcoin network"
);
store_typed_message(
state,
sender_contact_id,
sender_name,
&format!("TX relay request #{} ({} hex chars)", relay.request_id, relay.tx_hex.len()),
"tx_relay",
)
.await;
// Spawn async task to broadcast via Bitcoin RPC and track confirmations
let relay_state = Arc::clone(state);
let relay_contact = sender_contact_id;
tokio::spawn(async move {
handle_tx_relay_broadcast(relay, relay_contact, &relay_state).await;
});
}
Err(e) => warn!("Failed to decode TX relay payload: {}", e),
}
}
Some(MeshMessageType::TxRelayResponse) => {
dispatch_tx_relay_response(&envelope, sender_contact_id, sender_name, state).await;
}
Some(MeshMessageType::LightningRelay) => {
match message_types::decode_payload::<message_types::LightningRelayPayload>(
&envelope.v,
) {
Ok(relay) => {
info!(
request_id = relay.request_id,
amount_sats = relay.amount_sats,
"Lightning relay request received"
);
store_typed_message(
state,
sender_contact_id,
sender_name,
&format!("Lightning relay: {} sats", relay.amount_sats),
"lightning_relay",
)
.await;
// Will be wired to LND in Week 9
let _ = state.event_tx.send(MeshEvent::LightningRelayCompleted {
request_id: relay.request_id,
payment_hash: None,
error: Some("Lightning relay processing not yet wired".to_string()),
});
}
Err(e) => warn!("Failed to decode Lightning relay payload: {}", e),
}
}
Some(MeshMessageType::LightningRelayResponse) => {
match message_types::decode_payload::<message_types::LightningRelayResponsePayload>(
&envelope.v,
) {
Ok(resp) => {
let status = if resp.payment_hash.is_some() { "paid" } else { "failed" };
info!(request_id = resp.request_id, status, "Lightning relay response");
let text = if let Some(ref hash) = resp.payment_hash {
format!("Lightning paid! hash: {}...", &hash[..16.min(hash.len())])
} else {
format!("Lightning failed: {}", resp.error.as_deref().unwrap_or("unknown"))
};
store_typed_message(state, sender_contact_id, sender_name, &text, "lightning_relay_response").await;
let _ = state.event_tx.send(MeshEvent::LightningRelayCompleted {
request_id: resp.request_id,
payment_hash: resp.payment_hash,
error: resp.error,
});
}
Err(e) => warn!("Failed to decode Lightning relay response: {}", e),
}
}
Some(MeshMessageType::Invoice) => {
match message_types::decode_payload::<message_types::InvoicePayload>(&envelope.v) {
Ok(invoice) => {
let text = format!(
"Invoice: {} sats{}",
invoice.amount_sats,
invoice.memo.as_ref().map(|m| format!("{}", m)).unwrap_or_default()
);
store_typed_message(state, sender_contact_id, sender_name, &text, "invoice").await;
}
Err(e) => warn!("Failed to decode invoice payload: {}", e),
}
}
Some(MeshMessageType::Coordinate) => {
match message_types::decode_payload::<message_types::Coordinate>(&envelope.v) {
Ok(coord) => {
let text = format!(
"Location: {:.6}, {:.6}{}",
coord.lat_degrees(),
coord.lng_degrees(),
coord.label.as_ref().map(|l| format!(" ({})", l)).unwrap_or_default()
);
store_typed_message(state, sender_contact_id, sender_name, &text, "coordinate").await;
}
Err(e) => warn!("Failed to decode coordinate payload: {}", e),
}
}
Some(MeshMessageType::TxConfirmation) => {
dispatch_tx_confirmation(&envelope, sender_contact_id, sender_name, state).await;
}
Some(MeshMessageType::Text) => {
// Typed text message — extract and store as plain text
let text = String::from_utf8_lossy(&envelope.v).to_string();
store_plain_message(state, sender_contact_id, sender_name, &text).await;
}
_ => {
debug!(
msg_type = ?msg_type,
"Unhandled typed message type"
);
}
}
}
/// Dispatch a BlockHeader typed message.
async fn dispatch_block_header(
envelope: &TypedEnvelope,
sender_contact_id: u32,
sender_name: &str,
state: &Arc<MeshState>,
) {
// Compact binary format: height(8) + hash(32) + timestamp(4)
match super::super::bitcoin_relay::decode_compact_block_header(&envelope.v) {
Ok((height, hash_hex, timestamp)) => {
// Validate header before accepting
let last_known = state.block_header_cache.latest_height().await;
if !super::super::bitcoin_relay::validate_block_header(height, &hash_hex, timestamp, last_known) {
warn!(peer = sender_contact_id, height, "Rejected invalid block header");
return;
}
info!(
height,
hash = %hash_hex,
"Block header received via mesh"
);
// Store in block header cache for the Off-Grid Bitcoin panel
let header_payload = message_types::BlockHeaderPayload {
height,
hash: hash_hex.clone(),
prev_hash: String::new(),
timestamp,
announced_by: sender_name.to_string(),
};
let _ = state.block_header_cache.store_header(header_payload).await;
let text = format!(
"Block #{} — {}...{}",
height,
&hash_hex[..8.min(hash_hex.len())],
&hash_hex[hash_hex.len().saturating_sub(8)..]
);
store_typed_message(
state,
sender_contact_id,
sender_name,
&text,
"block_header",
)
.await;
let _ = state.event_tx.send(MeshEvent::BlockHeaderReceived {
height,
hash: hash_hex,
});
}
Err(e) => warn!("Failed to decode block header: {}", e),
}
}
/// Dispatch a TxRelayResponse typed message.
async fn dispatch_tx_relay_response(
envelope: &TypedEnvelope,
sender_contact_id: u32,
sender_name: &str,
state: &Arc<MeshState>,
) {
match message_types::decode_payload::<message_types::TxRelayResponsePayload>(&envelope.v) {
Ok(resp) => {
let status = if resp.txid.is_some() { "confirmed" } else { "failed" };
info!(
request_id = resp.request_id,
status,
error_code = resp.error_code.as_deref().unwrap_or("none"),
"TX relay response received"
);
let text = if let Some(ref txid) = resp.txid {
format!("TX relayed! txid: {}...{}", &txid[..8.min(txid.len())], &txid[txid.len().saturating_sub(8)..])
} else if let Some(ref code) = resp.error_code {
format!("TX relay failed [{}]: {}", code, resp.error.as_deref().unwrap_or("unknown"))
} else {
format!("TX relay failed: {}", resp.error.as_deref().unwrap_or("unknown"))
};
store_typed_message(state, sender_contact_id, sender_name, &text, "tx_relay_response").await;
// Store result for frontend polling
if let Some(ref tracker) = state.relay_tracker {
tracker.store_result(super::super::bitcoin_relay::RelayResult {
request_id: resp.request_id,
txid: resp.txid.clone(),
error: resp.error.clone(),
error_code: resp.error_code.clone(),
completed_at: chrono::Utc::now().to_rfc3339(),
}).await;
}
let _ = state.event_tx.send(MeshEvent::TxRelayCompleted {
request_id: resp.request_id,
txid: resp.txid,
error: resp.error,
});
}
Err(e) => warn!("Failed to decode TX relay response: {}", e),
}
}
/// Dispatch a TxConfirmation typed message.
async fn dispatch_tx_confirmation(
envelope: &TypedEnvelope,
sender_contact_id: u32,
sender_name: &str,
state: &Arc<MeshState>,
) {
match message_types::decode_payload::<message_types::TxConfirmationPayload>(&envelope.v) {
Ok(conf) => {
let status_text = if conf.confirmations >= 3 {
format!("TX {} confirmed ({}/3) at block #{}", &conf.txid[..12.min(conf.txid.len())], conf.confirmations, conf.block_height)
} else {
format!("TX {}{}/3 confirmations (block #{})", &conf.txid[..12.min(conf.txid.len())], conf.confirmations, conf.block_height)
};
info!(
txid = %conf.txid,
confirmations = conf.confirmations,
block_height = conf.block_height,
"TX confirmation update received"
);
store_typed_message(state, sender_contact_id, sender_name, &status_text, "tx_confirmation").await;
// Store confirmation for frontend polling
if let Some(ref tracker) = state.relay_tracker {
tracker.store_result(super::super::bitcoin_relay::RelayResult {
request_id: conf.request_id,
txid: Some(conf.txid.clone()),
error: None,
error_code: None,
completed_at: chrono::Utc::now().to_rfc3339(),
}).await;
}
let _ = state.event_tx.send(MeshEvent::TxRelayCompleted {
request_id: conf.request_id,
txid: Some(conf.txid),
error: None,
});
}
Err(e) => warn!("Failed to decode TX confirmation: {}", e),
}
}
@@ -0,0 +1,143 @@
//! Inbound frame dispatcher — routes device frames to the appropriate handler.
use super::decode::{
resolve_peer, store_plain_message, try_base64_typed, try_chunk_reassemble,
try_decrypt_base64, try_decrypt_ratchet_base64,
};
use super::dispatch::handle_typed_message;
use super::MeshState;
use super::super::message_types::TypedEnvelope;
use super::super::protocol;
use std::sync::Arc;
use tracing::{debug, info, warn};
/// Handle a single inbound frame from the device.
/// Returns `true` if contacts should be refreshed from the device.
pub(super) async fn handle_frame(
frame: &protocol::InboundFrame,
state: &Arc<MeshState>,
our_x25519_secret: &[u8; 32],
) -> bool {
let _ = our_x25519_secret; // reserved for future per-frame decryption
match frame.code {
protocol::PUSH_NEW_CONTACT | protocol::PUSH_CONTACT_ADVERT => {
info!(code = frame.code, "Contact discovery event — refreshing contacts");
return true; // Signal caller to fetch contacts
}
protocol::PUSH_ACK => {
debug!("Message delivery confirmed");
// Could track which message was ACKed from frame.data
}
protocol::PUSH_MESSAGES_WAITING => {
info!("Device has messages waiting — will sync");
return true; // Signal caller to sync immediately
}
protocol::RESP_CONTACT_MSG_V3 => {
// Direct message received (v3 format) — check for typed envelope first
match protocol::parse_contact_msg_v3_raw(&frame.data) {
Ok((sender_prefix, payload, _snr)) => {
if !payload.is_empty() {
let (contact_id, name) = resolve_peer(state, &sender_prefix).await;
if TypedEnvelope::is_typed(&payload) {
handle_typed_message(&payload, contact_id, &name, state).await;
} else if let Some(decoded) = try_base64_typed(&payload) {
handle_typed_message(&decoded, contact_id, &name, state).await;
} else if let Some(decoded) = try_decrypt_ratchet_base64(&payload, contact_id, state).await {
handle_typed_message(&decoded, contact_id, &name, state).await;
} else if let Some(decoded) = try_decrypt_base64(&payload, contact_id, state).await {
handle_typed_message(&decoded, contact_id, &name, state).await;
} else if let Some(decoded) = try_chunk_reassemble(&payload, contact_id, state).await {
handle_typed_message(&decoded, contact_id, &name, state).await;
} else if !payload.starts_with(b"MC") {
let text = String::from_utf8_lossy(&payload).to_string();
store_plain_message(state, contact_id, &name, &text).await;
info!(from = %sender_prefix, "Received mesh DM (v3)");
}
}
}
Err(e) => warn!("Failed to parse v3 message: {}", e),
}
}
protocol::RESP_CONTACT_MSG => {
// Direct message received (v1 format)
match protocol::parse_contact_msg_v1_raw(&frame.data) {
Ok((sender_prefix, payload)) => {
if !payload.is_empty() {
let (contact_id, name) = resolve_peer(state, &sender_prefix).await;
if TypedEnvelope::is_typed(&payload) {
handle_typed_message(&payload, contact_id, &name, state).await;
} else if let Some(decoded) = try_base64_typed(&payload) {
handle_typed_message(&decoded, contact_id, &name, state).await;
} else if let Some(decoded) = try_decrypt_ratchet_base64(&payload, contact_id, state).await {
handle_typed_message(&decoded, contact_id, &name, state).await;
} else if let Some(decoded) = try_decrypt_base64(&payload, contact_id, state).await {
handle_typed_message(&decoded, contact_id, &name, state).await;
} else if let Some(decoded) = try_chunk_reassemble(&payload, contact_id, state).await {
handle_typed_message(&decoded, contact_id, &name, state).await;
} else if !payload.starts_with(b"MC") {
let text = String::from_utf8_lossy(&payload).to_string();
store_plain_message(state, contact_id, &name, &text).await;
info!(from = %sender_prefix, "Received mesh DM (v1)");
}
}
}
Err(e) => warn!("Failed to parse v1 message: {}", e),
}
}
protocol::RESP_CHANNEL_MSG_V3 => {
// Channel broadcast received (v3) — check for typed envelope
match protocol::parse_channel_msg_v3_raw(&frame.data) {
Ok((channel_idx, payload)) => {
if !payload.is_empty() {
let chan_contact_id = u32::MAX - (channel_idx as u32);
let chan_name = format!("Channel {}", channel_idx);
if TypedEnvelope::is_typed(&payload) {
handle_typed_message(&payload, chan_contact_id, &chan_name, state).await;
} else {
let text = String::from_utf8_lossy(&payload).to_string();
store_plain_message(state, chan_contact_id, &chan_name, &text).await;
info!(channel = channel_idx, "Received mesh channel message (v3)");
}
}
}
Err(e) => warn!("Failed to parse v3 channel message: {}", e),
}
}
protocol::RESP_CHANNEL_MSG => {
// Channel broadcast received (v1)
match protocol::parse_channel_msg_v1_raw(&frame.data) {
Ok((channel_idx, payload)) => {
if !payload.is_empty() {
let chan_contact_id = u32::MAX - (channel_idx as u32);
let chan_name = format!("Channel {}", channel_idx);
if TypedEnvelope::is_typed(&payload) {
handle_typed_message(&payload, chan_contact_id, &chan_name, state).await;
} else {
let text = String::from_utf8_lossy(&payload).to_string();
store_plain_message(state, chan_contact_id, &chan_name, &text).await;
info!(channel = channel_idx, "Received mesh channel message");
}
}
}
Err(e) => warn!("Failed to parse channel message: {}", e),
}
}
protocol::PUSH_LOG_DATA | protocol::PUSH_PATH_UPDATE | protocol::PUSH_RAW_DATA => {
// Internal device logging/path data — safe to ignore
}
_ => {
if protocol::is_push_notification(frame.code) {
debug!(code = frame.code, "Unhandled push notification");
}
}
}
false
}
+218
View File
@@ -0,0 +1,218 @@
//! Background mesh listener task.
//!
//! Runs as a long-lived tokio task that:
//! - Maintains the serial connection to the Meshcore device
//! - Reads incoming frames and dispatches events
//! - Periodically broadcasts our identity advertisement
//! - Reconnects on device disconnect
//! - Manages peer cache and message store
mod bitcoin;
mod decode;
mod dispatch;
mod frames;
mod session;
use super::types::*;
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{broadcast, mpsc, RwLock};
use tracing::{error, info};
/// How often to broadcast our identity advertisement (seconds).
const ADVERT_INTERVAL: Duration = Duration::from_secs(60);
/// How often to poll for queued messages when no push notifications.
const SYNC_INTERVAL: Duration = Duration::from_secs(10);
/// Maximum stored messages (circular buffer).
const MAX_MESSAGES: usize = 100;
/// Initial delay before reconnection attempt after device disconnect.
const RECONNECT_DELAY_INIT: Duration = Duration::from_secs(5);
/// Maximum reconnect delay (cap for exponential backoff).
const RECONNECT_DELAY_MAX: Duration = Duration::from_secs(60);
/// Number of consecutive write failures before we consider the device dead
/// and trigger a reconnection cycle.
const MAX_CONSECUTIVE_WRITE_FAILURES: u32 = 3;
/// Command sent from MeshService to the listener task (which owns the serial port).
pub enum MeshCommand {
SendText { dest_pubkey_prefix: [u8; 6], payload: Vec<u8> },
/// Send pre-encoded binary (TypedEnvelope wire bytes) to a peer.
SendRaw { dest_pubkey_prefix: [u8; 6], payload: Vec<u8> },
/// Broadcast pre-encoded binary on a mesh channel.
BroadcastChannel { channel: u8, payload: Vec<u8> },
SendAdvert,
}
/// Shared state for the mesh listener, accessible from RPC handlers.
pub struct MeshState {
pub peers: RwLock<HashMap<u32, MeshPeer>>,
pub messages: RwLock<VecDeque<MeshMessage>>,
pub shared_secrets: RwLock<HashMap<u32, [u8; 32]>>,
pub status: RwLock<MeshStatus>,
pub event_tx: broadcast::Sender<MeshEvent>,
pub cmd_tx: mpsc::Sender<MeshCommand>,
next_message_id: RwLock<u64>,
/// Block header cache — populated when receiving headers from internet-connected peers.
pub block_header_cache: Arc<super::bitcoin_relay::BlockHeaderCache>,
/// Relay tracker — stores completed relay results for frontend polling.
pub relay_tracker: Option<Arc<super::bitcoin_relay::RelayTracker>>,
/// Steganography mode for outgoing/incoming messages.
pub stego_mode: super::steganography::SteganographyMode,
/// Chunk reassembly buffer for multi-frame messages.
chunk_buffer: RwLock<HashMap<(u32, u8), ChunkAssembly>>,
/// Double Ratchet session manager for forward-secret encryption.
pub session_manager: Arc<super::session::SessionManager>,
/// Whether to encrypt directed relay messages (config toggle for rollback).
pub encrypt_relay: bool,
}
/// In-progress chunk reassembly for a multi-frame message.
struct ChunkAssembly {
chunks: HashMap<u8, String>,
total: u8,
created: std::time::Instant,
}
impl MeshState {
pub fn new(
channel_name: &str,
block_header_cache: Arc<super::bitcoin_relay::BlockHeaderCache>,
relay_tracker: Option<Arc<super::bitcoin_relay::RelayTracker>>,
stego_mode: super::steganography::SteganographyMode,
encrypt_relay: bool,
session_manager: Arc<super::session::SessionManager>,
) -> (Arc<Self>, broadcast::Receiver<MeshEvent>, mpsc::Receiver<MeshCommand>) {
let (tx, rx) = broadcast::channel(64);
let (cmd_tx, cmd_rx) = mpsc::channel(32);
let state = Arc::new(Self {
peers: RwLock::new(HashMap::new()),
messages: RwLock::new(VecDeque::new()),
shared_secrets: RwLock::new(HashMap::new()),
cmd_tx,
status: RwLock::new(MeshStatus {
enabled: true,
device_type: DeviceType::Unknown,
device_path: None,
device_connected: false,
firmware_version: None,
self_node_id: None,
self_advert_name: None,
peer_count: 0,
channel_name: channel_name.to_string(),
messages_sent: 0,
messages_received: 0,
}),
event_tx: tx,
next_message_id: RwLock::new(1),
block_header_cache,
relay_tracker,
stego_mode,
chunk_buffer: RwLock::new(HashMap::new()),
session_manager,
encrypt_relay,
});
(state, rx, cmd_rx)
}
pub async fn next_id(&self) -> u64 {
let mut id = self.next_message_id.write().await;
let current = *id;
*id += 1;
current
}
pub async fn store_message(&self, msg: MeshMessage) {
let mut messages = self.messages.write().await;
messages.push_back(msg);
if messages.len() > MAX_MESSAGES {
messages.pop_front();
}
}
async fn update_peer_count(&self) {
let count = self.peers.read().await.len();
self.status.write().await.peer_count = count;
}
}
/// Spawn the background mesh listener task.
///
/// This task manages the full lifecycle:
/// 1. Detect and connect to Meshcore device
/// 2. Initialize and set advert name
/// 3. Main loop: read frames, dispatch events, periodic adverts
/// 4. Reconnect on disconnect
pub fn spawn_mesh_listener(
state: Arc<MeshState>,
device_path: Option<String>,
our_did: String,
our_ed_pubkey_hex: String,
our_x25519_secret: [u8; 32],
our_x25519_pubkey_hex: String,
shutdown: tokio::sync::watch::Receiver<bool>,
cmd_rx: mpsc::Receiver<MeshCommand>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut shutdown = shutdown;
let mut cmd_rx = cmd_rx;
let mut reconnect_delay = RECONNECT_DELAY_INIT;
loop {
if *shutdown.borrow() {
info!("Mesh listener shutting down");
return;
}
match session::run_mesh_session(
&state,
device_path.as_deref(),
&our_did,
&our_ed_pubkey_hex,
&our_x25519_secret,
&our_x25519_pubkey_hex,
&mut shutdown,
&mut cmd_rx,
)
.await
{
Ok(()) => {
info!("Mesh session ended cleanly");
// Session was established before ending — reset backoff
reconnect_delay = RECONNECT_DELAY_INIT;
}
Err(e) => {
// Check if session was ever connected (vs failed to open)
let was_connected = state.status.read().await.device_connected;
if was_connected {
reconnect_delay = RECONNECT_DELAY_INIT;
}
error!("Mesh session error: {} (retry in {:?})", e, reconnect_delay);
}
}
// Update status to disconnected
{
let mut status = state.status.write().await;
status.device_connected = false;
status.device_path = None;
}
let _ = state.event_tx.send(MeshEvent::DeviceDisconnected);
// Wait before reconnecting (exponential backoff)
tokio::select! {
_ = tokio::time::sleep(reconnect_delay) => {},
_ = shutdown.changed() => {
if *shutdown.borrow() { return; }
},
}
// Increase backoff for next failure, cap at max
reconnect_delay = (reconnect_delay * 2).min(RECONNECT_DELAY_MAX);
}
})
}
@@ -0,0 +1,337 @@
//! Mesh session lifecycle: connect, initialize, main loop.
use super::{
frames, MeshCommand, MeshState,
ADVERT_INTERVAL, MAX_CONSECUTIVE_WRITE_FAILURES, SYNC_INTERVAL,
};
use super::super::serial::MeshcoreDevice;
use super::super::types::*;
use anyhow::Result;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
/// Scan all candidate serial ports and open the first Meshcore device found.
async fn auto_detect_and_open() -> Result<(String, MeshcoreDevice, DeviceInfo)> {
let paths = super::super::serial::detect_serial_devices().await;
if paths.is_empty() {
anyhow::bail!("No serial devices found in /dev");
}
for path in &paths {
debug!(path = %path, "Probing for Meshcore device");
match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => {
info!(path = %path, firmware = %info.firmware_version, "Found Meshcore device via auto-detect");
return Ok((path.clone(), dev, info));
}
Err(e) => debug!(path = %path, error = %e, "Not a Meshcore device"),
},
Err(e) => debug!(path = %path, error = %e, "Could not open serial port"),
}
}
anyhow::bail!("No Meshcore device found on {} candidate ports: {:?}", paths.len(), paths)
}
/// Fetch the contacts list from the device and update the peer cache.
async fn refresh_contacts(
device: &mut MeshcoreDevice,
state: &Arc<MeshState>,
) {
match device.get_contacts().await {
Ok(contacts) => {
let mut peers = state.peers.write().await;
for (idx, contact) in contacts.iter().enumerate() {
let contact_id = idx as u32;
let existing = peers.get(&contact_id);
let peer = super::super::types::MeshPeer {
contact_id,
advert_name: contact.advert_name.clone(),
did: existing.and_then(|p| p.did.clone()),
pubkey_hex: Some(contact.public_key_hex.clone()),
x25519_pubkey: existing.and_then(|p| p.x25519_pubkey),
rssi: None,
snr: None,
last_heard: chrono::Utc::now().to_rfc3339(),
hops: 0,
};
peers.insert(contact_id, peer);
}
drop(peers);
state.update_peer_count().await;
if !contacts.is_empty() {
info!(count = contacts.len(), "Refreshed mesh contacts");
}
}
Err(e) => {
warn!("Failed to fetch contacts: {}", e);
}
}
}
/// Drain any queued messages from the device.
/// Returns `true` if a write/communication error occurred (for failure tracking).
async fn sync_queued_messages(
device: &mut MeshcoreDevice,
state: &Arc<MeshState>,
our_x25519_secret: &[u8; 32],
) -> bool {
match device.sync_messages().await {
Ok(frames) => {
for frame in &frames {
frames::handle_frame(frame, state, our_x25519_secret).await;
}
if !frames.is_empty() {
info!(count = frames.len(), "Synced queued mesh messages");
}
false
}
Err(e) => {
debug!("Message sync: {}", e);
true
}
}
}
/// Run a single mesh session (connect, initialize, main loop).
pub(super) async fn run_mesh_session(
state: &Arc<MeshState>,
preferred_path: Option<&str>,
our_did: &str,
_our_ed_pubkey_hex: &str,
our_x25519_secret: &[u8; 32],
_our_x25519_pubkey_hex: &str,
shutdown: &mut tokio::sync::watch::Receiver<bool>,
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
) -> Result<()> {
// Detect device — try preferred path first, fall back to auto-detect
let (device_path, mut device, device_info) = if let Some(path) = preferred_path {
match MeshcoreDevice::open(path).await {
Ok(mut dev) => match dev.initialize().await {
Ok(info) => (path.to_string(), dev, info),
Err(e) => {
warn!("Preferred path {} handshake failed: {} — trying auto-detect", path, e);
auto_detect_and_open().await?
}
},
Err(e) => {
warn!("Preferred path {} open failed: {} — trying auto-detect", path, e);
auto_detect_and_open().await?
}
}
} else {
auto_detect_and_open().await?
};
// Update status
{
let mut status = state.status.write().await;
status.device_connected = true;
status.device_type = DeviceType::Meshcore;
status.device_path = Some(device_path.clone());
status.firmware_version = Some(device_info.firmware_version.clone());
status.self_node_id = Some(device_info.node_id);
status.self_advert_name = device.advert_name.clone();
}
let _ = state.event_tx.send(MeshEvent::DeviceConnected(device_info));
// Set advert name to something identifiable
let short_did = our_did.chars().skip(8).take(8).collect::<String>();
let advert_name = format!("Archy-{}", short_did);
if let Err(e) = device.set_advert_name(&advert_name).await {
warn!("Failed to set advert name: {}", e);
}
// Broadcast our advertisement so other nodes can discover us
if let Err(e) = device.send_self_advert().await {
warn!("Failed to send initial advert: {}", e);
}
// Fetch existing contacts from the device
refresh_contacts(&mut device, state).await;
// Sync any queued messages from before we connected
let _ = sync_queued_messages(&mut device, state, our_x25519_secret).await;
// Main loop
let mut advert_timer = tokio::time::interval(ADVERT_INTERVAL);
let mut sync_timer = tokio::time::interval(SYNC_INTERVAL);
advert_timer.tick().await; // skip first immediate tick
sync_timer.tick().await;
let mut consecutive_write_failures: u32 = 0;
loop {
// If too many consecutive writes have failed, the serial port is dead —
// bail out so the outer loop can reconnect to a (possibly re-enumerated) device.
if consecutive_write_failures >= MAX_CONSECUTIVE_WRITE_FAILURES {
error!(
failures = consecutive_write_failures,
"Serial port unresponsive — triggering reconnection"
);
anyhow::bail!("Serial port unresponsive after {} consecutive write failures", consecutive_write_failures);
}
tokio::select! {
// Check for incoming frames
frame_result = device.try_recv_frame() => {
match frame_result {
Ok(Some(frame)) => {
// Successful read resets the failure counter
consecutive_write_failures = 0;
let should_action = frames::handle_frame(
&frame,
state,
our_x25519_secret,
).await;
if should_action {
// Contact discovery or messages waiting — sync both
refresh_contacts(&mut device, state).await;
if sync_queued_messages(&mut device, state, our_x25519_secret).await {
consecutive_write_failures += 1;
}
}
}
Ok(None) => {
// No complete frame yet, that's fine
tokio::time::sleep(Duration::from_millis(50)).await;
}
Err(e) => {
error!("Serial read error: {}", e);
return Err(e);
}
}
}
// Periodic advertisement broadcast + contact refresh
_ = advert_timer.tick() => {
debug!("Periodic self-advert broadcast");
if let Err(e) = device.send_self_advert().await {
consecutive_write_failures += 1;
warn!(failures = consecutive_write_failures, "Failed to send advert: {}", e);
} else {
consecutive_write_failures = 0;
}
refresh_contacts(&mut device, state).await;
}
// Process send commands from MeshService
Some(cmd) = cmd_rx.recv() => {
handle_send_command(cmd, &mut device, state, &mut consecutive_write_failures).await;
}
// Periodic message sync
_ = sync_timer.tick() => {
if sync_queued_messages(&mut device, state, our_x25519_secret).await {
consecutive_write_failures += 1;
debug!(failures = consecutive_write_failures, "Message sync failed");
} else {
consecutive_write_failures = 0;
}
}
// Shutdown signal
_ = shutdown.changed() => {
if *shutdown.borrow() {
info!("Mesh listener received shutdown signal");
return Ok(());
}
}
}
}
}
/// Process a single outbound command from MeshService.
async fn handle_send_command(
cmd: MeshCommand,
device: &mut MeshcoreDevice,
state: &Arc<MeshState>,
consecutive_write_failures: &mut u32,
) {
match cmd {
MeshCommand::SendText { dest_pubkey_prefix, payload } => {
if let Err(e) = device.send_text(&dest_pubkey_prefix, &payload).await {
*consecutive_write_failures += 1;
warn!(failures = *consecutive_write_failures, "Failed to send text via mesh: {}", e);
} else {
*consecutive_write_failures = 0;
info!(dest = %hex::encode(dest_pubkey_prefix), len = payload.len(), "Sent mesh message");
}
}
MeshCommand::SendRaw { dest_pubkey_prefix, payload } => {
// Apply steganographic encoding if configured
let wire_payload = if state.stego_mode != super::super::steganography::SteganographyMode::Normal
&& payload.first() == Some(&super::super::message_types::TYPED_MESSAGE_MARKER)
{
match super::super::steganography::encode_typed_wire(state.stego_mode, &payload) {
Ok(stego) => stego,
Err(e) => {
warn!("Stego encode failed, sending plain: {}", e);
payload
}
}
} else {
payload
};
// Base64 encode, then chunk if >140 chars (LoRa 160 byte limit)
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&wire_payload);
if encoded.len() <= 140 {
// Single frame — fits in one LoRa packet
if let Err(e) = device.send_text(&dest_pubkey_prefix, encoded.as_bytes()).await {
*consecutive_write_failures += 1;
warn!(failures = *consecutive_write_failures, "Failed to send raw via mesh: {}", e);
} else {
*consecutive_write_failures = 0;
info!(dest = %hex::encode(dest_pubkey_prefix), len = encoded.len(), "Sent raw mesh message");
}
} else {
// Multi-frame chunking: "MCxxyyzz..." where xx=msg_id, yy=chunk_idx, zz=total_chunks
static CHUNK_MSG_ID: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0);
let msg_id = CHUNK_MSG_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let chunk_data_size = 132; // 160 - 8 header bytes ("MCxxyyzz") = 152, leave margin
let chunks: Vec<&str> = encoded.as_bytes().chunks(chunk_data_size)
.map(|c| std::str::from_utf8(c).unwrap_or(""))
.collect();
let total = chunks.len() as u8;
info!(
dest = %hex::encode(dest_pubkey_prefix),
raw_len = wire_payload.len(),
b64_len = encoded.len(),
chunks = total,
"Sending chunked mesh message"
);
for (idx, chunk) in chunks.iter().enumerate() {
let frame = format!("MC{:02x}{:02x}{:02x}{}", msg_id, idx as u8, total, chunk);
if let Err(e) = device.send_text(&dest_pubkey_prefix, frame.as_bytes()).await {
*consecutive_write_failures += 1;
warn!(failures = *consecutive_write_failures, chunk = idx, "Chunk send failed: {}", e);
break;
}
// Small delay between chunks to avoid overwhelming the radio
tokio::time::sleep(Duration::from_millis(500)).await;
}
*consecutive_write_failures = 0;
}
}
MeshCommand::BroadcastChannel { channel, payload } => {
if let Err(e) = device.send_channel_text(channel, &payload).await {
*consecutive_write_failures += 1;
warn!(failures = *consecutive_write_failures, "Failed to broadcast on channel {}: {}", channel, e);
} else {
*consecutive_write_failures = 0;
info!(channel, len = payload.len(), "Broadcast on mesh channel");
}
}
MeshCommand::SendAdvert => {
if let Err(e) = device.send_self_advert().await {
*consecutive_write_failures += 1;
warn!(failures = *consecutive_write_failures, "Failed to send advert: {}", e);
} else {
*consecutive_write_failures = 0;
}
}
}
}