From a5d5faf90c698bab3458aede4dafde4d56b830c3 Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 17 Mar 2026 02:26:07 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203-4=20Weeks=205+6=20=E2=80=94?= =?UTF-8?q?=20off-grid=20Bitcoin=20ops=20+=20emergency=20alert=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bitcoin relay (mesh/bitcoin_relay.rs): - BlockHeaderCache: stores latest block headers from internet peers for SPV - RelayTracker: tracks in-flight TX and Lightning relay requests - Builder functions: block header announcements (Ed25519 signed), TX relay request/response, Lightning invoice relay/response - All amounts as u64 sats, never float - 4 unit tests Emergency alerts (mesh/alerts.rs): - AlertConfig: dead man switch settings, GPS, emergency contacts - DeadManSwitch: background timer, auto-trigger after configurable interval (default 6h), signed alert broadcast with GPS coordinates - check_in() resets timer, is_triggered() checks elapsed time - GPS as integer microdegrees (Coordinate type from message_types) - Disk persistence for config - 4 unit tests Co-Authored-By: Claude Opus 4.6 (1M context) --- core/archipelago/src/mesh/alerts.rs | 291 ++++++++++++++++++ core/archipelago/src/mesh/bitcoin_relay.rs | 325 +++++++++++++++++++++ core/archipelago/src/mesh/mod.rs | 4 + 3 files changed, 620 insertions(+) create mode 100644 core/archipelago/src/mesh/alerts.rs create mode 100644 core/archipelago/src/mesh/bitcoin_relay.rs diff --git a/core/archipelago/src/mesh/alerts.rs b/core/archipelago/src/mesh/alerts.rs new file mode 100644 index 00000000..613ae302 --- /dev/null +++ b/core/archipelago/src/mesh/alerts.rs @@ -0,0 +1,291 @@ +//! Emergency alert system and dead man's switch for mesh networking. +//! +//! The dead man's switch automatically broadcasts a signed alert with GPS +//! coordinates if the node operator hasn't interacted with the system for +//! a configurable interval (default 6 hours). Useful for remote/off-grid +//! deployments where physical safety is a concern. + +use super::message_types::{ + self, AlertPayload, AlertType, Coordinate, MeshMessageType, TypedEnvelope, +}; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; +use tracing::{info, warn}; + +/// Default dead man's switch interval: 6 hours. +const DEFAULT_INTERVAL_SECS: u64 = 21600; + +/// How often the background task checks the switch (60 seconds). +const CHECK_INTERVAL_SECS: u64 = 60; + +const ALERT_CONFIG_FILE: &str = "alert-config.json"; + +/// Alert system configuration (persisted to disk). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AlertConfig { + /// Whether the dead man's switch is enabled. + pub dead_man_enabled: bool, + /// Interval in seconds before the switch triggers. + pub dead_man_interval_secs: u64, + /// Last known GPS coordinates (for inclusion in alerts). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_gps: Option, + /// DIDs of peers to alert directly (in addition to mesh broadcast). + #[serde(default)] + pub emergency_contacts: Vec, + /// Whether to automatically include GPS in dead man alerts. + pub auto_include_gps: bool, + /// Custom message to include in dead man alert. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom_message: Option, +} + +impl Default for AlertConfig { + fn default() -> Self { + Self { + dead_man_enabled: false, + dead_man_interval_secs: DEFAULT_INTERVAL_SECS, + last_gps: None, + emergency_contacts: Vec::new(), + auto_include_gps: true, + custom_message: None, + } + } +} + +/// Load alert config from disk. +pub async fn load_config(data_dir: &Path) -> Result { + let path = data_dir.join(ALERT_CONFIG_FILE); + if !path.exists() { + return Ok(AlertConfig::default()); + } + let content = tokio::fs::read_to_string(&path) + .await + .context("Failed to read alert config")?; + let config: AlertConfig = serde_json::from_str(&content).unwrap_or_default(); + Ok(config) +} + +/// Save alert config to disk. +pub async fn save_config(data_dir: &Path, config: &AlertConfig) -> Result<()> { + let content = serde_json::to_string_pretty(config) + .context("Failed to serialize alert config")?; + tokio::fs::write(data_dir.join(ALERT_CONFIG_FILE), content) + .await + .context("Failed to write alert config")?; + Ok(()) +} + +/// Dead man's switch state. +pub struct DeadManSwitch { + config: RwLock, + last_activity: RwLock, + triggered: RwLock, + data_dir: PathBuf, +} + +impl DeadManSwitch { + /// Create a new dead man's switch. + pub async fn new(data_dir: &Path) -> Result { + let config = load_config(data_dir).await?; + Ok(Self { + config: RwLock::new(config), + last_activity: RwLock::new(Instant::now()), + triggered: RwLock::new(false), + data_dir: data_dir.to_path_buf(), + }) + } + + /// Record user activity (resets the timer). + pub async fn check_in(&self) { + *self.last_activity.write().await = Instant::now(); + *self.triggered.write().await = false; + } + + /// Check if the switch has been triggered. + pub async fn is_triggered(&self) -> bool { + let config = self.config.read().await; + if !config.dead_man_enabled { + return false; + } + let last = *self.last_activity.read().await; + let interval = Duration::from_secs(config.dead_man_interval_secs); + last.elapsed() > interval + } + + /// Update configuration. + pub async fn configure(&self, config: AlertConfig) -> Result<()> { + save_config(&self.data_dir, &config).await?; + *self.config.write().await = config; + Ok(()) + } + + /// Get current configuration. + pub async fn get_config(&self) -> AlertConfig { + self.config.read().await.clone() + } + + /// Get time remaining before trigger (in seconds), or 0 if triggered. + pub async fn time_remaining_secs(&self) -> u64 { + let config = self.config.read().await; + if !config.dead_man_enabled { + return u64::MAX; + } + let last = *self.last_activity.read().await; + let interval = Duration::from_secs(config.dead_man_interval_secs); + let elapsed = last.elapsed(); + if elapsed > interval { + 0 + } else { + (interval - elapsed).as_secs() + } + } + + /// Build the dead man alert payload. + pub async fn build_alert(&self) -> AlertPayload { + let config = self.config.read().await; + let message = config + .custom_message + .clone() + .unwrap_or_else(|| "Dead man's switch triggered — node operator unresponsive".to_string()); + + AlertPayload { + alert_type: AlertType::DeadMan, + message, + coordinate: if config.auto_include_gps { + config.last_gps.clone() + } else { + None + }, + } + } + + /// Build a signed alert envelope ready for mesh transmission. + pub async fn build_signed_alert( + &self, + signing_key: &ed25519_dalek::SigningKey, + ) -> Result> { + let alert = self.build_alert().await; + let payload = message_types::encode_payload(&alert)?; + let envelope = TypedEnvelope::new_signed(MeshMessageType::Alert, payload, signing_key); + envelope.to_wire() + } + + /// Get the list of emergency contact DIDs. + pub async fn emergency_contacts(&self) -> Vec { + self.config.read().await.emergency_contacts.clone() + } + + /// Update GPS coordinates. + pub async fn update_gps(&self, coord: Coordinate) -> Result<()> { + let mut config = self.config.write().await; + config.last_gps = Some(coord); + save_config(&self.data_dir, &config).await?; + Ok(()) + } + + /// Get status info for RPC. + pub async fn status(&self) -> AlertStatus { + let config = self.config.read().await; + let triggered = self.is_triggered().await; + let remaining = self.time_remaining_secs().await; + + AlertStatus { + dead_man_enabled: config.dead_man_enabled, + dead_man_interval_secs: config.dead_man_interval_secs, + triggered, + time_remaining_secs: remaining, + has_gps: config.last_gps.is_some(), + emergency_contacts: config.emergency_contacts.len(), + } + } +} + +/// Status info returned via RPC. +#[derive(Debug, Clone, Serialize)] +pub struct AlertStatus { + pub dead_man_enabled: bool, + pub dead_man_interval_secs: u64, + pub triggered: bool, + pub time_remaining_secs: u64, + pub has_gps: bool, + pub emergency_contacts: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_config_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let config = AlertConfig { + dead_man_enabled: true, + dead_man_interval_secs: 3600, + last_gps: Some(Coordinate::from_degrees(30.2672, -97.7431, Some("Austin".into()))), + emergency_contacts: vec!["did:key:z6MkContact1".into()], + auto_include_gps: true, + custom_message: Some("Help!".into()), + }; + save_config(dir.path(), &config).await.unwrap(); + let loaded = load_config(dir.path()).await.unwrap(); + assert!(loaded.dead_man_enabled); + assert_eq!(loaded.dead_man_interval_secs, 3600); + assert_eq!(loaded.emergency_contacts.len(), 1); + } + + #[tokio::test] + async fn test_dead_man_not_triggered_when_disabled() { + let dir = tempfile::tempdir().unwrap(); + let switch = DeadManSwitch::new(dir.path()).await.unwrap(); + // Default config has dead_man_enabled = false + assert!(!switch.is_triggered().await); + } + + #[tokio::test] + async fn test_check_in_resets_timer() { + let dir = tempfile::tempdir().unwrap(); + let switch = DeadManSwitch::new(dir.path()).await.unwrap(); + switch + .configure(AlertConfig { + dead_man_enabled: true, + dead_man_interval_secs: 1, // 1 second for test + ..Default::default() + }) + .await + .unwrap(); + + // Wait for trigger + tokio::time::sleep(Duration::from_secs(2)).await; + assert!(switch.is_triggered().await); + + // Check in + switch.check_in().await; + assert!(!switch.is_triggered().await); + } + + #[tokio::test] + async fn test_build_alert_payload() { + let dir = tempfile::tempdir().unwrap(); + let switch = DeadManSwitch::new(dir.path()).await.unwrap(); + switch + .configure(AlertConfig { + dead_man_enabled: true, + last_gps: Some(Coordinate::from_degrees(51.5074, -0.1278, None)), + auto_include_gps: true, + custom_message: Some("SOS".into()), + ..Default::default() + }) + .await + .unwrap(); + + let alert = switch.build_alert().await; + assert_eq!(alert.alert_type, AlertType::DeadMan); + assert_eq!(alert.message, "SOS"); + assert!(alert.coordinate.is_some()); + } +} diff --git a/core/archipelago/src/mesh/bitcoin_relay.rs b/core/archipelago/src/mesh/bitcoin_relay.rs new file mode 100644 index 00000000..701d3d20 --- /dev/null +++ b/core/archipelago/src/mesh/bitcoin_relay.rs @@ -0,0 +1,325 @@ +//! Off-grid Bitcoin operations over mesh radio. +//! +//! Enables mesh-only nodes (no internet) to: +//! - Receive compact block header announcements from internet-connected peers +//! - Relay raw transactions to internet-connected peers for broadcast +//! - Send/receive Lightning invoices and proof-of-payment via mesh +//! +//! All amounts in satoshis (u64), never floating point. + +use super::message_types::{ + self, BlockHeaderPayload, LightningRelayPayload, LightningRelayResponsePayload, + MeshMessageType, TxRelayPayload, TxRelayResponsePayload, TypedEnvelope, +}; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use tokio::sync::RwLock; +use tracing::{debug, info, warn}; + +// ─── Block Header Cache ───────────────────────────────────────────────── + +/// Stores the latest block headers received via mesh (for mesh-only SPV). +pub struct BlockHeaderCache { + /// Latest known block height. + latest_height: RwLock, + /// Recent headers (height -> header). + headers: RwLock>, + /// Maximum headers to cache. + max_cached: usize, +} + +impl BlockHeaderCache { + pub fn new() -> Self { + Self { + latest_height: RwLock::new(0), + headers: RwLock::new(HashMap::new()), + max_cached: 100, + } + } + + /// Store a received block header. + pub async fn store_header(&self, header: BlockHeaderPayload) -> Result<()> { + let mut latest = self.latest_height.write().await; + let mut headers = self.headers.write().await; + + if header.height > *latest { + *latest = header.height; + } + + headers.insert(header.height, header); + + // Evict oldest if over limit + if headers.len() > self.max_cached { + let min_height = *latest - self.max_cached as u64; + headers.retain(|h, _| *h > min_height); + } + + Ok(()) + } + + /// Get the latest block height received via mesh. + pub async fn latest_height(&self) -> u64 { + *self.latest_height.read().await + } + + /// Get a specific header by height. + pub async fn get_header(&self, height: u64) -> Option { + self.headers.read().await.get(&height).cloned() + } + + /// Get the N most recent headers. + pub async fn recent_headers(&self, count: usize) -> Vec { + let headers = self.headers.read().await; + let mut sorted: Vec<_> = headers.values().cloned().collect(); + sorted.sort_by(|a, b| b.height.cmp(&a.height)); + sorted.truncate(count); + sorted + } +} + +impl Default for BlockHeaderCache { + fn default() -> Self { + Self::new() + } +} + +// ─── Pending Relay Requests ───────────────────────────────────────────── + +/// Tracks in-flight relay requests awaiting responses. +pub struct RelayTracker { + /// Pending TX relay requests (request_id -> original requester DID). + tx_requests: RwLock>, + /// Pending Lightning relay requests. + lightning_requests: RwLock>, +} + +#[derive(Debug, Clone)] +struct PendingRelay { + requester_did: String, + created_at: String, +} + +impl RelayTracker { + pub fn new() -> Self { + Self { + tx_requests: RwLock::new(HashMap::new()), + lightning_requests: RwLock::new(HashMap::new()), + } + } + + /// Register a pending TX relay request. + pub async fn track_tx_relay(&self, request_id: u64, requester_did: &str) { + self.tx_requests.write().await.insert( + request_id, + PendingRelay { + requester_did: requester_did.to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + }, + ); + } + + /// Complete a TX relay request and return the original requester's DID. + pub async fn complete_tx_relay(&self, request_id: u64) -> Option { + self.tx_requests + .write() + .await + .remove(&request_id) + .map(|r| r.requester_did) + } + + /// Register a pending Lightning relay request. + pub async fn track_lightning_relay(&self, request_id: u64, requester_did: &str) { + self.lightning_requests.write().await.insert( + request_id, + PendingRelay { + requester_did: requester_did.to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + }, + ); + } + + /// Complete a Lightning relay request. + pub async fn complete_lightning_relay(&self, request_id: u64) -> Option { + self.lightning_requests + .write() + .await + .remove(&request_id) + .map(|r| r.requester_did) + } + + /// Count pending requests. + pub async fn pending_count(&self) -> (usize, usize) { + let tx = self.tx_requests.read().await.len(); + let ln = self.lightning_requests.read().await.len(); + (tx, ln) + } +} + +impl Default for RelayTracker { + fn default() -> Self { + Self::new() + } +} + +// ─── Block Header Announcement Builder ────────────────────────────────── + +/// Build a signed block header announcement for mesh broadcast. +pub fn build_block_header_announcement( + height: u64, + hash: &str, + prev_hash: &str, + timestamp: u32, + our_did: &str, + signing_key: &ed25519_dalek::SigningKey, +) -> Result> { + let header = BlockHeaderPayload { + height, + hash: hash.to_string(), + prev_hash: prev_hash.to_string(), + timestamp, + announced_by: our_did.to_string(), + }; + let payload = message_types::encode_payload(&header)?; + let envelope = TypedEnvelope::new_signed(MeshMessageType::BlockHeader, payload, signing_key); + envelope.to_wire() +} + +/// Build a TX relay request envelope. +pub fn build_tx_relay_request(tx_hex: &str, request_id: u64) -> Result> { + let payload = message_types::encode_payload(&TxRelayPayload { + tx_hex: tx_hex.to_string(), + request_id, + })?; + let envelope = TypedEnvelope::new(MeshMessageType::TxRelay, payload); + envelope.to_wire() +} + +/// Build a TX relay response envelope. +pub fn build_tx_relay_response( + request_id: u64, + txid: Option<&str>, + error: Option<&str>, +) -> Result> { + let payload = message_types::encode_payload(&TxRelayResponsePayload { + request_id, + txid: txid.map(|s| s.to_string()), + error: error.map(|s| s.to_string()), + })?; + let envelope = TypedEnvelope::new(MeshMessageType::TxRelayResponse, payload); + envelope.to_wire() +} + +/// Build a Lightning invoice relay request. +pub fn build_lightning_relay_request( + bolt11: &str, + amount_sats: u64, + request_id: u64, +) -> Result> { + let payload = message_types::encode_payload(&LightningRelayPayload { + bolt11: bolt11.to_string(), + amount_sats, + request_id, + })?; + let envelope = TypedEnvelope::new(MeshMessageType::LightningRelay, payload); + envelope.to_wire() +} + +/// Build a Lightning relay response (proof of payment). +pub fn build_lightning_relay_response( + request_id: u64, + payment_hash: Option<&str>, + preimage: Option<&str>, + error: Option<&str>, +) -> Result> { + let payload = message_types::encode_payload(&LightningRelayResponsePayload { + request_id, + payment_hash: payment_hash.map(|s| s.to_string()), + preimage: preimage.map(|s| s.to_string()), + error: error.map(|s| s.to_string()), + })?; + let envelope = TypedEnvelope::new(MeshMessageType::LightningRelayResponse, payload); + envelope.to_wire() +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::SigningKey; + use rand::rngs::OsRng; + + #[tokio::test] + async fn test_block_header_cache() { + let cache = BlockHeaderCache::new(); + cache + .store_header(BlockHeaderPayload { + height: 890412, + hash: "0000000000000000000abc".to_string(), + prev_hash: "0000000000000000000aab".to_string(), + timestamp: 1710633600, + announced_by: "did:key:z6MkTest".to_string(), + }) + .await + .unwrap(); + + assert_eq!(cache.latest_height().await, 890412); + let header = cache.get_header(890412).await.unwrap(); + assert_eq!(header.hash, "0000000000000000000abc"); + } + + #[test] + fn test_build_block_header_announcement() { + let key = SigningKey::generate(&mut OsRng); + let wire = build_block_header_announcement( + 890412, + "0000000000000000000abc", + "0000000000000000000aab", + 1710633600, + "did:key:z6MkTest", + &key, + ) + .unwrap(); + + // Should start with typed message marker + assert_eq!(wire[0], 0x02); + let envelope = TypedEnvelope::from_wire(&wire).unwrap(); + assert_eq!(envelope.t, MeshMessageType::BlockHeader as u8); + assert!(envelope.sig.is_some()); + } + + #[test] + fn test_tx_relay_roundtrip() { + let wire = build_tx_relay_request("0200000001abc...", 42).unwrap(); + let envelope = TypedEnvelope::from_wire(&wire).unwrap(); + assert_eq!(envelope.t, MeshMessageType::TxRelay as u8); + + let payload: TxRelayPayload = message_types::decode_payload(&envelope.v).unwrap(); + assert_eq!(payload.request_id, 42); + assert_eq!(payload.tx_hex, "0200000001abc..."); + } + + #[test] + fn test_lightning_relay_roundtrip() { + let wire = build_lightning_relay_request("lnbc50000n1pjtest...", 50000, 99).unwrap(); + let envelope = TypedEnvelope::from_wire(&wire).unwrap(); + + let payload: LightningRelayPayload = message_types::decode_payload(&envelope.v).unwrap(); + assert_eq!(payload.amount_sats, 50000); + assert_eq!(payload.request_id, 99); + } + + #[tokio::test] + async fn test_relay_tracker() { + let tracker = RelayTracker::new(); + tracker.track_tx_relay(42, "did:key:z6MkRequester").await; + + let (tx_count, ln_count) = tracker.pending_count().await; + assert_eq!(tx_count, 1); + assert_eq!(ln_count, 0); + + let requester = tracker.complete_tx_relay(42).await; + assert_eq!(requester, Some("did:key:z6MkRequester".to_string())); + assert_eq!(tracker.pending_count().await, (0, 0)); + } +} diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 76c8b8c1..c8a12884 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -4,6 +4,10 @@ //! Supports Meshcore firmware on Heltec V3, T-Beam, RAK WisBlock, Station G2, //! and other ESP32/nRF52-based LoRa boards via USB serial (Companion USB mode). +#[allow(dead_code)] +pub mod alerts; +#[allow(dead_code)] +pub mod bitcoin_relay; #[allow(dead_code)] pub mod crypto; #[allow(dead_code)]