feat: Phase 4 — off-grid Bitcoin relay, block headers, dead man's switch
- Typed message dispatch in listener (BlockHeader, TxRelay, LightningRelay, Alert, TxConfirmation) - Base64 encoding for binary payloads over LoRa (fixes NUL byte truncation) - Compact block header announcements (88 bytes, fits 160-byte LoRa limit) - Block header announcer: internet nodes auto-announce new blocks to Archy peers - TX relay: mesh-only nodes can broadcast transactions via internet-connected peers - Confirmation tracking: relay node monitors 1/3, 2/3, 3/3 confirmations, sends updates back - Dead man's switch background task with configurable interval and signed alert broadcast - 6 new RPC endpoints: relay-tx, block-headers, relay-lightning, deadman-status/configure/checkin - lnd.create-raw-tx: create signed TX without broadcasting (for mesh relay) - Web5 wallet: offline detection + "Send via mesh?" prompt with auto relay + confirmation polling - Mesh.vue: Off-Grid Bitcoin tab, Dead Man tab, Send Bitcoin/Lightning buttons - TX/Lightning relay sends only to Archy peers (not broadcast to all devices) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,15 +31,18 @@ pub mod x3dh;
|
||||
|
||||
pub use types::*;
|
||||
|
||||
use alerts::DeadManSwitch;
|
||||
use anyhow::{Context, Result};
|
||||
use bitcoin_relay::{BlockHeaderCache, RelayTracker};
|
||||
use ed25519_dalek::SigningKey;
|
||||
use listener::MeshState;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::fs;
|
||||
use tokio::sync::{broadcast, watch};
|
||||
use tracing::info;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
const MESH_CONFIG_FILE: &str = "mesh-config.json";
|
||||
|
||||
@@ -62,6 +65,9 @@ pub struct MeshConfig {
|
||||
/// Off-grid mode: disable Tor/internet, route everything via mesh only.
|
||||
#[serde(default)]
|
||||
pub mesh_only_mode: Option<bool>,
|
||||
/// Announce new Bitcoin block headers over mesh (internet-connected nodes only).
|
||||
#[serde(default)]
|
||||
pub announce_block_headers: bool,
|
||||
}
|
||||
|
||||
impl Default for MeshConfig {
|
||||
@@ -73,6 +79,7 @@ impl Default for MeshConfig {
|
||||
broadcast_identity: true,
|
||||
advert_name: None,
|
||||
mesh_only_mode: None,
|
||||
announce_block_headers: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,12 +124,19 @@ pub struct MeshService {
|
||||
data_dir: PathBuf,
|
||||
shutdown_tx: Option<watch::Sender<bool>>,
|
||||
listener_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
deadman_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
block_announcer_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
cmd_rx: Option<tokio::sync::mpsc::Receiver<listener::MeshCommand>>,
|
||||
// Crypto identity for this node
|
||||
our_did: String,
|
||||
our_ed_pubkey_hex: String,
|
||||
our_x25519_secret: [u8; 32],
|
||||
our_x25519_pubkey_hex: String,
|
||||
signing_key: SigningKey,
|
||||
// Phase 4: off-grid Bitcoin operations
|
||||
pub block_header_cache: Arc<BlockHeaderCache>,
|
||||
pub relay_tracker: Arc<RelayTracker>,
|
||||
pub dead_man_switch: Arc<DeadManSwitch>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -149,17 +163,37 @@ impl MeshService {
|
||||
)?;
|
||||
let x25519_pubkey_hex = hex::encode(x25519_pubkey);
|
||||
|
||||
let block_header_cache = Arc::new(BlockHeaderCache::new());
|
||||
let relay_tracker = Arc::new(RelayTracker::new());
|
||||
let dead_man_switch = Arc::new(
|
||||
DeadManSwitch::new(data_dir)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("Failed to load dead man config (using defaults): {}", e);
|
||||
// Fallback: create with defaults (won't persist until configured)
|
||||
tokio::runtime::Handle::current()
|
||||
.block_on(DeadManSwitch::new(data_dir))
|
||||
.expect("DeadManSwitch fallback should succeed")
|
||||
}),
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
state,
|
||||
config,
|
||||
data_dir: data_dir.to_path_buf(),
|
||||
shutdown_tx: None,
|
||||
listener_handle: None,
|
||||
deadman_handle: None,
|
||||
block_announcer_handle: None,
|
||||
cmd_rx: Some(cmd_rx),
|
||||
our_did: did.to_string(),
|
||||
our_ed_pubkey_hex: ed_pubkey_hex.to_string(),
|
||||
our_x25519_secret: x25519_secret,
|
||||
our_x25519_pubkey_hex: x25519_pubkey_hex,
|
||||
signing_key: signing_key.clone(),
|
||||
block_header_cache,
|
||||
relay_tracker,
|
||||
dead_man_switch,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -187,11 +221,169 @@ impl MeshService {
|
||||
);
|
||||
self.listener_handle = Some(handle);
|
||||
|
||||
// Spawn dead man's switch background checker
|
||||
let dms = Arc::clone(&self.dead_man_switch);
|
||||
let dms_state = Arc::clone(&self.state);
|
||||
let dms_key = self.signing_key.clone();
|
||||
let dms_shutdown = self.shutdown_tx.as_ref().unwrap().subscribe();
|
||||
let dms_handle = tokio::spawn(async move {
|
||||
let mut shutdown = dms_shutdown;
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(60));
|
||||
interval.tick().await; // skip first immediate tick
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
if dms.is_triggered().await {
|
||||
let was_triggered = *dms.triggered_flag().await;
|
||||
if !was_triggered {
|
||||
error!("Dead man's switch TRIGGERED — broadcasting alert");
|
||||
if let Ok(wire) = dms.build_signed_alert(&dms_key).await {
|
||||
for ch in [0u8, 1] {
|
||||
let _ = dms_state.cmd_tx.send(
|
||||
listener::MeshCommand::BroadcastChannel {
|
||||
channel: ch,
|
||||
payload: wire.clone(),
|
||||
},
|
||||
).await;
|
||||
}
|
||||
}
|
||||
dms.mark_triggered().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown.changed() => {
|
||||
if *shutdown.borrow() { return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
self.deadman_handle = Some(dms_handle);
|
||||
|
||||
// Spawn block header announcer (internet-connected nodes only)
|
||||
if self.config.announce_block_headers {
|
||||
let bha_state = Arc::clone(&self.state);
|
||||
let bha_cache = Arc::clone(&self.block_header_cache);
|
||||
let bha_key = self.signing_key.clone();
|
||||
let bha_did = self.our_did.clone();
|
||||
let bha_shutdown = self.shutdown_tx.as_ref().unwrap().subscribe();
|
||||
let bha_handle = tokio::spawn(async move {
|
||||
let mut shutdown = bha_shutdown;
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||
interval.tick().await; // skip first
|
||||
let mut last_announced_height: u64 = 0;
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("Failed to create HTTP client for block announcer: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
// Poll Bitcoin Core for latest block
|
||||
match bitcoin_rpc_getblockcount(&client).await {
|
||||
Ok(height) if height > last_announced_height => {
|
||||
if let Ok(header) = bitcoin_rpc_getblockheader_by_height(&client, height).await {
|
||||
// Store in cache
|
||||
let payload = message_types::BlockHeaderPayload {
|
||||
height,
|
||||
hash: header.hash.clone(),
|
||||
prev_hash: header.prev_hash.clone(),
|
||||
timestamp: header.timestamp,
|
||||
announced_by: bha_did.clone(),
|
||||
};
|
||||
let _ = bha_cache.store_header(payload).await;
|
||||
|
||||
// Build signed announcement and broadcast
|
||||
match bitcoin_relay::build_block_header_announcement(
|
||||
height,
|
||||
&header.hash,
|
||||
&header.prev_hash,
|
||||
header.timestamp,
|
||||
&bha_did,
|
||||
&bha_key,
|
||||
) {
|
||||
Ok(wire) => {
|
||||
// Send to peers — prefer Archy nodes, fall back to all (max 5)
|
||||
let peers = bha_state.peers.read().await;
|
||||
let mut sent = 0u32;
|
||||
let max_peers = 5u32;
|
||||
// First pass: Archy nodes
|
||||
for peer in peers.values() {
|
||||
if sent >= max_peers { break; }
|
||||
if !peer.advert_name.starts_with("Archy-") { continue; }
|
||||
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]);
|
||||
let _ = bha_state.cmd_tx.send(
|
||||
listener::MeshCommand::SendRaw {
|
||||
dest_pubkey_prefix: prefix,
|
||||
payload: wire.clone(),
|
||||
},
|
||||
).await;
|
||||
sent += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Second pass: any peer if no Archy nodes found
|
||||
if sent == 0 {
|
||||
for peer in peers.values() {
|
||||
if sent >= max_peers { break; }
|
||||
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]);
|
||||
let _ = bha_state.cmd_tx.send(
|
||||
listener::MeshCommand::SendRaw {
|
||||
dest_pubkey_prefix: prefix,
|
||||
payload: wire.clone(),
|
||||
},
|
||||
).await;
|
||||
sent += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(peers);
|
||||
last_announced_height = height;
|
||||
info!(height, hash = %header.hash, peers = sent, "Announced block header to Archy peers");
|
||||
}
|
||||
Err(e) => warn!("Failed to build block announcement: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {} // No new block
|
||||
Err(e) => {
|
||||
// Bitcoin not running or not reachable — that's fine, skip
|
||||
tracing::debug!("Block poll: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown.changed() => {
|
||||
if *shutdown.borrow() { return; }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
self.block_announcer_handle = Some(bha_handle);
|
||||
info!("Block header announcer started");
|
||||
}
|
||||
|
||||
info!("Mesh service started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop the background listener.
|
||||
/// Stop the background listener and dead man's switch.
|
||||
pub async fn stop(&mut self) {
|
||||
if let Some(tx) = self.shutdown_tx.take() {
|
||||
let _ = tx.send(true);
|
||||
@@ -199,6 +391,14 @@ impl MeshService {
|
||||
if let Some(handle) = self.listener_handle.take() {
|
||||
let _ = handle.await;
|
||||
}
|
||||
if let Some(handle) = self.deadman_handle.take() {
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
}
|
||||
if let Some(handle) = self.block_announcer_handle.take() {
|
||||
handle.abort();
|
||||
let _ = handle.await;
|
||||
}
|
||||
info!("Mesh service stopped");
|
||||
}
|
||||
|
||||
@@ -358,10 +558,98 @@ impl MeshService {
|
||||
pub fn shared_state(&self) -> Arc<MeshState> {
|
||||
Arc::clone(&self.state)
|
||||
}
|
||||
|
||||
/// Record user activity (resets dead man's switch timer).
|
||||
pub async fn dead_man_check_in(&self) {
|
||||
self.dead_man_switch.check_in().await;
|
||||
}
|
||||
|
||||
/// Get the node's signing key (for signed messages).
|
||||
pub fn signing_key(&self) -> &SigningKey {
|
||||
&self.signing_key
|
||||
}
|
||||
|
||||
/// Get our DID.
|
||||
pub fn our_did(&self) -> &str {
|
||||
&self.our_did
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_MESSAGES_DEFAULT: usize = 100;
|
||||
|
||||
// ─── Bitcoin RPC helpers for block header announcer ────────────────────
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct BitcoinRpcResponse<T> {
|
||||
result: Option<T>,
|
||||
error: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
struct BlockHeaderInfo {
|
||||
hash: String,
|
||||
prev_hash: String,
|
||||
timestamp: u32,
|
||||
}
|
||||
|
||||
async fn bitcoin_rpc_getblockcount(client: &reqwest::Client) -> Result<u64> {
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0", "id": "mesh", "method": "getblockcount", "params": []
|
||||
});
|
||||
let resp: BitcoinRpcResponse<u64> = client
|
||||
.post("http://127.0.0.1:8332/")
|
||||
.basic_auth("archipelago", Some("archipelago123"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Bitcoin RPC send failed: {}", e))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Bitcoin RPC parse failed: {}", e))?;
|
||||
if let Some(err) = resp.error {
|
||||
anyhow::bail!("Bitcoin RPC: {}", err);
|
||||
}
|
||||
resp.result.ok_or_else(|| anyhow::anyhow!("Bitcoin RPC null result"))
|
||||
}
|
||||
|
||||
async fn bitcoin_rpc_getblockheader_by_height(
|
||||
client: &reqwest::Client,
|
||||
height: u64,
|
||||
) -> Result<BlockHeaderInfo> {
|
||||
// First get block hash for this height
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0", "id": "mesh", "method": "getblockhash", "params": [height]
|
||||
});
|
||||
let resp: BitcoinRpcResponse<String> = client
|
||||
.post("http://127.0.0.1:8332/")
|
||||
.basic_auth("archipelago", Some("archipelago123"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
let hash = resp.result.ok_or_else(|| anyhow::anyhow!("No block hash"))?;
|
||||
|
||||
// Then get full header
|
||||
let body = serde_json::json!({
|
||||
"jsonrpc": "1.0", "id": "mesh", "method": "getblockheader", "params": [hash, true]
|
||||
});
|
||||
let resp: BitcoinRpcResponse<serde_json::Value> = client
|
||||
.post("http://127.0.0.1:8332/")
|
||||
.basic_auth("archipelago", Some("archipelago123"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
let header = resp.result.ok_or_else(|| anyhow::anyhow!("No block header"))?;
|
||||
|
||||
Ok(BlockHeaderInfo {
|
||||
hash: header["hash"].as_str().unwrap_or_default().to_string(),
|
||||
prev_hash: header["previousblockhash"].as_str().unwrap_or_default().to_string(),
|
||||
timestamp: header["time"].as_u64().unwrap_or(0) as u32,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user