feat: Phase 3-4 Weeks 5+6 — off-grid Bitcoin ops + emergency alert system
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) <noreply@anthropic.com>
This commit is contained in:
parent
df478c4a1e
commit
a5d5faf90c
291
core/archipelago/src/mesh/alerts.rs
Normal file
291
core/archipelago/src/mesh/alerts.rs
Normal file
@ -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<Coordinate>,
|
||||||
|
/// DIDs of peers to alert directly (in addition to mesh broadcast).
|
||||||
|
#[serde(default)]
|
||||||
|
pub emergency_contacts: Vec<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AlertConfig> {
|
||||||
|
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<AlertConfig>,
|
||||||
|
last_activity: RwLock<Instant>,
|
||||||
|
triggered: RwLock<bool>,
|
||||||
|
data_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeadManSwitch {
|
||||||
|
/// Create a new dead man's switch.
|
||||||
|
pub async fn new(data_dir: &Path) -> Result<Self> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<String> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
325
core/archipelago/src/mesh/bitcoin_relay.rs
Normal file
325
core/archipelago/src/mesh/bitcoin_relay.rs
Normal file
@ -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<u64>,
|
||||||
|
/// Recent headers (height -> header).
|
||||||
|
headers: RwLock<HashMap<u64, BlockHeaderPayload>>,
|
||||||
|
/// 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<BlockHeaderPayload> {
|
||||||
|
self.headers.read().await.get(&height).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the N most recent headers.
|
||||||
|
pub async fn recent_headers(&self, count: usize) -> Vec<BlockHeaderPayload> {
|
||||||
|
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<HashMap<u64, PendingRelay>>,
|
||||||
|
/// Pending Lightning relay requests.
|
||||||
|
lightning_requests: RwLock<HashMap<u64, PendingRelay>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,10 @@
|
|||||||
//! Supports Meshcore firmware on Heltec V3, T-Beam, RAK WisBlock, Station G2,
|
//! 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).
|
//! 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)]
|
#[allow(dead_code)]
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user