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:
Dorian
2026-03-17 15:51:56 +00:00
parent 4b7c765cd1
commit d1ac098edb
13 changed files with 2091 additions and 126 deletions

View File

@@ -676,6 +676,97 @@ impl RpcHandler {
}))
}
/// Create a signed raw transaction WITHOUT broadcasting.
/// Used for mesh relay: create the TX locally, then relay the hex to an
/// internet-connected peer who broadcasts it.
pub(super) async fn handle_lnd_create_raw_tx(&self, params: Option<serde_json::Value>) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let addr = params.get("addr")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing 'addr'"))?;
let amount_sats = params.get("amount_sats")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing 'amount_sats'"))?;
if amount_sats < 546 {
anyhow::bail!("Amount must be at least 546 sats (dust limit)");
}
if amount_sats > 2_100_000_000_000_000 {
anyhow::bail!("Amount exceeds 21M BTC");
}
let (client, macaroon_hex) = self.lnd_client().await?;
// Step 1: Fund a PSBT with the desired output
let fee_rate = params.get("fee_rate").and_then(|v| v.as_u64()).unwrap_or(5);
let fund_body = serde_json::json!({
"raw": {
"outputs": { addr: amount_sats }
},
"sat_per_vbyte": fee_rate,
"spend_unconfirmed": false,
});
let resp = client
.post("https://127.0.0.1:8080/v2/wallet/psbt/fund")
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&fund_body)
.send()
.await
.context("Failed to fund PSBT via LND")?;
let status = resp.status();
let body: serde_json::Value = resp.json().await
.context("Failed to parse fund response")?;
if !status.is_success() {
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Failed to create TX: {}", msg));
}
let funded_psbt = body.get("funded_psbt")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("No funded_psbt in response"))?;
// Step 2: Finalize (LND auto-signs with hot wallet keys)
let finalize_body = serde_json::json!({
"funded_psbt": funded_psbt,
});
let resp = client
.post("https://127.0.0.1:8080/v2/wallet/psbt/finalize")
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&finalize_body)
.send()
.await
.context("Failed to finalize PSBT")?;
let status = resp.status();
let body: serde_json::Value = resp.json().await
.context("Failed to parse finalize response")?;
if !status.is_success() {
let msg = body.get("message").and_then(|v| v.as_str()).unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Failed to sign TX: {}", msg));
}
// raw_final_tx is the hex-encoded signed transaction — ready for broadcast
let raw_final_tx = body.get("raw_final_tx")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("No raw_final_tx in response"))?
.to_string();
info!(addr, amount_sats, tx_len = raw_final_tx.len(), "Created raw TX for mesh relay (NOT broadcast)");
Ok(serde_json::json!({
"raw_tx_hex": raw_final_tx,
"amount_sats": amount_sats,
"addr": addr,
"broadcast": false,
}))
}
/// List on-chain transactions from LND.
/// Returns all transactions, with incoming (amount > 0) flagged.
pub(super) async fn handle_lnd_gettransactions(&self) -> Result<serde_json::Value> {

View File

@@ -411,6 +411,231 @@ impl RpcHandler {
}
}
// ─── Phase 4: Off-Grid Bitcoin Operations ────────────────────────────
/// mesh.relay-tx — Send a raw transaction for relay by an internet-connected mesh peer.
pub(super) async fn handle_mesh_relay_tx(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let tx_hex = params["tx_hex"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing tx_hex"))?;
if tx_hex.len() < 20 || tx_hex.len() > 200_000 {
anyhow::bail!("Invalid tx_hex length");
}
// Validate hex
if hex::decode(tx_hex).is_err() {
anyhow::bail!("tx_hex is not valid hexadecimal");
}
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let request_id = chrono::Utc::now().timestamp() as u64;
svc.relay_tracker.track_tx_relay(request_id, svc.our_did()).await;
let wire = crate::mesh::bitcoin_relay::build_tx_relay_request(tx_hex, request_id)?;
// Send ONLY to Archipelago peers (Archy-* nodes), not broadcast to all devices
let peers = svc.peers().await;
let mut sent_count = 0u32;
for peer in &peers {
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 _ = svc.shared_state()
.cmd_tx
.send(crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload: wire.clone(),
})
.await;
sent_count += 1;
}
}
}
}
info!(request_id, tx_len = tx_hex.len(), archy_peers = sent_count, "TX relay sent to Archy peers only");
Ok(serde_json::json!({
"request_id": request_id,
"queued": true,
"tx_hex_len": tx_hex.len(),
}))
}
/// mesh.block-headers — Get cached block headers received from mesh peers.
pub(super) async fn handle_mesh_block_headers(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let count = params
.as_ref()
.and_then(|p| p["count"].as_u64())
.unwrap_or(10) as usize;
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let headers = svc.block_header_cache.recent_headers(count).await;
let latest = svc.block_header_cache.latest_height().await;
Ok(serde_json::json!({
"headers": headers.iter().map(|h| serde_json::json!({
"height": h.height,
"hash": h.hash,
"prev_hash": h.prev_hash,
"timestamp": h.timestamp,
"announced_by": h.announced_by,
})).collect::<Vec<_>>(),
"latest_height": latest,
"count": headers.len(),
}))
}
/// mesh.relay-lightning — Send a Lightning invoice for payment by an internet-connected peer.
pub(super) async fn handle_mesh_relay_lightning(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let bolt11 = params["bolt11"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing bolt11"))?;
let amount_sats = params["amount_sats"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing amount_sats"))?;
if !bolt11.starts_with("lnbc") && !bolt11.starts_with("lntb") {
anyhow::bail!("Invalid bolt11 invoice — must start with lnbc or lntb");
}
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let request_id = chrono::Utc::now().timestamp() as u64;
svc.relay_tracker.track_lightning_relay(request_id, svc.our_did()).await;
let wire = crate::mesh::bitcoin_relay::build_lightning_relay_request(
bolt11, amount_sats, request_id,
)?;
// Send ONLY to Archipelago peers, not broadcast
let peers = svc.peers().await;
let mut sent_count = 0u32;
for peer in &peers {
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 _ = svc.shared_state()
.cmd_tx
.send(crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload: wire.clone(),
})
.await;
sent_count += 1;
}
}
}
}
info!(request_id, amount_sats, archy_peers = sent_count, "Lightning relay sent to Archy peers only");
Ok(serde_json::json!({
"request_id": request_id,
"queued": true,
"amount_sats": amount_sats,
}))
}
/// mesh.deadman-status — Get dead man's switch status.
pub(super) async fn handle_mesh_deadman_status(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let status = svc.dead_man_switch.status().await;
Ok(serde_json::to_value(status)?)
}
/// mesh.deadman-configure — Configure the dead man's switch.
pub(super) async fn handle_mesh_deadman_configure(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let mut config = svc.dead_man_switch.get_config().await;
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
config.dead_man_enabled = enabled;
}
if let Some(interval) = params.get("interval_secs").and_then(|v| v.as_u64()) {
if interval < 60 {
anyhow::bail!("Interval must be at least 60 seconds");
}
config.dead_man_interval_secs = interval;
}
if let (Some(lat), Some(lng)) = (
params.get("lat").and_then(|v| v.as_f64()),
params.get("lng").and_then(|v| v.as_f64()),
) {
let label = params.get("label").and_then(|v| v.as_str()).map(|s| s.to_string());
config.last_gps = Some(Coordinate::from_degrees(lat, lng, label));
}
if let Some(contacts) = params.get("contacts").and_then(|v| v.as_array()) {
config.emergency_contacts = contacts
.iter()
.filter_map(|c| c.as_str().map(|s| s.to_string()))
.collect();
}
if let Some(msg) = params.get("custom_message").and_then(|v| v.as_str()) {
config.custom_message = Some(msg.to_string());
}
if let Some(auto_gps) = params.get("auto_gps").and_then(|v| v.as_bool()) {
config.auto_include_gps = auto_gps;
}
svc.dead_man_switch.configure(config).await?;
// Reset timer on configure
svc.dead_man_switch.check_in().await;
let status = svc.dead_man_switch.status().await;
info!("Dead man's switch configured");
Ok(serde_json::to_value(status)?)
}
/// mesh.deadman-checkin — Heartbeat to reset the dead man's switch timer.
pub(super) async fn handle_mesh_deadman_checkin(&self) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
let svc = service.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
svc.dead_man_check_in().await;
let remaining = svc.dead_man_switch.time_remaining_secs().await;
Ok(serde_json::json!({
"checked_in": true,
"time_remaining_secs": remaining,
}))
}
/// mesh.rotate-prekeys — Force prekey rotation for X3DH.
pub(super) async fn handle_mesh_rotate_prekeys(&self) -> Result<serde_json::Value> {
// Load identity signing key

View File

@@ -490,6 +490,7 @@ impl RpcHandler {
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
"lnd.create-raw-tx" => self.handle_lnd_create_raw_tx(params).await,
"lnd.gettransactions" => self.handle_lnd_gettransactions().await,
"lnd.connect-info" => self.handle_lnd_connect_info().await,
@@ -652,6 +653,13 @@ impl RpcHandler {
"mesh.outbox" => self.handle_mesh_outbox(params).await,
"mesh.session-status" => self.handle_mesh_session_status(params).await,
"mesh.rotate-prekeys" => self.handle_mesh_rotate_prekeys().await,
// Phase 4: Off-grid Bitcoin operations
"mesh.relay-tx" => self.handle_mesh_relay_tx(params).await,
"mesh.block-headers" => self.handle_mesh_block_headers(params).await,
"mesh.relay-lightning" => self.handle_mesh_relay_lightning(params).await,
"mesh.deadman-status" => self.handle_mesh_deadman_status().await,
"mesh.deadman-configure" => self.handle_mesh_deadman_configure(params).await,
"mesh.deadman-checkin" => self.handle_mesh_deadman_checkin().await,
// Transport layer (unified routing)
"transport.status" => self.handle_transport_status().await,

View File

@@ -175,6 +175,16 @@ impl DeadManSwitch {
envelope.to_wire()
}
/// Check if the alert has already been sent (prevents re-broadcasting every 60s).
pub async fn triggered_flag(&self) -> tokio::sync::RwLockReadGuard<'_, bool> {
self.triggered.read().await
}
/// Mark the switch as having fired (alert already sent).
pub async fn mark_triggered(&self) {
*self.triggered.write().await = true;
}
/// Get the list of emergency contact DIDs.
pub async fn emergency_contacts(&self) -> Vec<String> {
self.config.read().await.emergency_contacts.clone()

View File

@@ -165,27 +165,46 @@ impl Default for RelayTracker {
// ─── Block Header Announcement Builder ──────────────────────────────────
/// Build a signed block header announcement for mesh broadcast.
/// Build a compact block header announcement for mesh broadcast.
/// Uses raw binary (not CBOR) to fit within the 160-byte LoRa limit:
/// height(8 LE) + hash_raw(32) + timestamp(4 LE) = 44 bytes payload
/// Wrapped in unsigned TypedEnvelope (~25 bytes overhead) = ~69 total.
pub fn build_block_header_announcement(
height: u64,
hash: &str,
prev_hash: &str,
_prev_hash: &str,
timestamp: u32,
our_did: &str,
signing_key: &ed25519_dalek::SigningKey,
_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);
let hash_bytes = hex::decode(hash).context("Invalid block hash hex")?;
if hash_bytes.len() != 32 {
anyhow::bail!("Block hash must be 32 bytes, got {}", hash_bytes.len());
}
// Compact binary: height(8) + hash(32) + timestamp(4) = 44 bytes
let mut payload = Vec::with_capacity(44);
payload.extend_from_slice(&height.to_le_bytes());
payload.extend_from_slice(&hash_bytes);
payload.extend_from_slice(&timestamp.to_le_bytes());
// Use unsigned envelope to save 64 bytes (no Ed25519 signature)
let envelope = TypedEnvelope::new(MeshMessageType::BlockHeader, payload);
envelope.to_wire()
}
/// Decode a compact block header from raw binary payload.
/// Returns (height, hash_hex, timestamp).
pub fn decode_compact_block_header(payload: &[u8]) -> Result<(u64, String, u32)> {
if payload.len() < 44 {
anyhow::bail!("Compact block header too short: {} bytes", payload.len());
}
let height = u64::from_le_bytes(payload[0..8].try_into().unwrap());
let hash_hex = hex::encode(&payload[8..40]);
let timestamp = u32::from_le_bytes(payload[40..44].try_into().unwrap());
Ok((height, hash_hex, timestamp))
}
/// 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 {

View File

@@ -8,6 +8,7 @@
//! - Manages peer cache and message store
use super::crypto;
use super::message_types::{self, MeshMessageType, TypedEnvelope};
use super::protocol;
use super::serial::MeshcoreDevice;
use super::types::*;
@@ -31,9 +32,17 @@ const MAX_MESSAGES: usize = 100;
/// Delay before reconnection attempt after device disconnect.
const RECONNECT_DELAY: Duration = Duration::from_secs(10);
/// 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,
}
@@ -220,20 +229,33 @@ async fn run_mesh_session(
refresh_contacts(&mut device, state).await;
// Sync any queued messages from before we connected
sync_queued_messages(&mut device, state, our_x25519_secret).await;
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 = handle_frame(
&frame,
state,
@@ -242,7 +264,9 @@ async fn run_mesh_session(
if should_action {
// Contact discovery or messages waiting — sync both
refresh_contacts(&mut device, state).await;
sync_queued_messages(&mut device, state, our_x25519_secret).await;
if sync_queued_messages(&mut device, state, our_x25519_secret).await {
consecutive_write_failures += 1;
}
}
}
Ok(None) => {
@@ -260,7 +284,10 @@ async fn run_mesh_session(
_ = advert_timer.tick() => {
debug!("Periodic self-advert broadcast");
if let Err(e) = device.send_self_advert().await {
warn!("Failed to send advert: {}", e);
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;
}
@@ -270,14 +297,40 @@ async fn run_mesh_session(
match cmd {
MeshCommand::SendText { dest_pubkey_prefix, payload } => {
if let Err(e) = device.send_text(&dest_pubkey_prefix, &payload).await {
warn!("Failed to send text via mesh: {}", e);
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 } => {
// Base64 encode binary payloads — Meshcore truncates at NUL bytes in text mode
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&payload);
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), raw_len = payload.len(), wire_len = encoded.len(), "Sent raw mesh message (base64)");
}
}
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 {
warn!("Failed to send advert: {}", e);
consecutive_write_failures += 1;
warn!(failures = consecutive_write_failures, "Failed to send advert: {}", e);
} else {
consecutive_write_failures = 0;
}
}
}
@@ -285,7 +338,12 @@ async fn run_mesh_session(
// Periodic message sync
_ = sync_timer.tick() => {
sync_queued_messages(&mut device, state, our_x25519_secret).await;
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
@@ -323,33 +381,20 @@ async fn handle_frame(
}
protocol::RESP_CONTACT_MSG_V3 => {
// Direct message received (v3 format)
match protocol::parse_contact_msg_v3(&frame.data) {
Ok((sender_prefix, text, _snr)) => {
if !text.is_empty() {
let peer_name = {
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()))
};
let (contact_id, name) = peer_name.unwrap_or((0, sender_prefix.clone()));
let msg_id = state.next_id().await;
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Received,
peer_contact_id: contact_id,
peer_name: Some(name),
plaintext: text,
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted: false,
};
state.store_message(msg.clone()).await;
state.status.write().await.messages_received += 1;
info!(from = %sender_prefix, "Received mesh DM (v3)");
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
// 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 {
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),
@@ -358,32 +403,19 @@ async fn handle_frame(
protocol::RESP_CONTACT_MSG => {
// Direct message received (v1 format)
match protocol::parse_contact_msg_v1(&frame.data) {
Ok((sender_prefix, text)) => {
if !text.is_empty() {
let peer_name = {
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()))
};
let (contact_id, name) = peer_name.unwrap_or((0, sender_prefix.clone()));
let msg_id = state.next_id().await;
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Received,
peer_contact_id: contact_id,
peer_name: Some(name),
plaintext: text,
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted: false,
};
state.store_message(msg.clone()).await;
state.status.write().await.messages_received += 1;
info!(from = %sender_prefix, "Received mesh DM (v1)");
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
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 {
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),
@@ -391,26 +423,19 @@ async fn handle_frame(
}
protocol::RESP_CHANNEL_MSG_V3 => {
// Channel broadcast received (v3)
match protocol::parse_channel_msg_v3(&frame.data) {
Ok((channel_idx, text)) => {
if !text.is_empty() {
let msg_id = state.next_id().await;
let chan_contact_id = -((channel_idx as i32) + 1);
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Received,
peer_contact_id: chan_contact_id as u32,
peer_name: Some(format!("Channel {}", channel_idx)),
plaintext: text,
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted: false,
};
state.store_message(msg.clone()).await;
state.status.write().await.messages_received += 1;
info!(channel = channel_idx, "Received mesh channel message (v3)");
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
// 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),
@@ -419,25 +444,18 @@ async fn handle_frame(
protocol::RESP_CHANNEL_MSG => {
// Channel broadcast received (v1)
match protocol::parse_channel_msg_v1(&frame.data) {
Ok((channel_idx, text)) => {
if !text.is_empty() {
let msg_id = state.next_id().await;
let chan_contact_id = -((channel_idx as i32) + 1);
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Received,
peer_contact_id: chan_contact_id as u32,
peer_name: Some(format!("Channel {}", channel_idx)),
plaintext: text,
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: true,
encrypted: false,
};
state.store_message(msg.clone()).await;
state.status.write().await.messages_received += 1;
info!(channel = channel_idx, "Received mesh channel message");
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
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),
@@ -599,11 +617,12 @@ async fn handle_received_message(
}
/// 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 {
@@ -612,9 +631,11 @@ async fn sync_queued_messages(
if !frames.is_empty() {
info!(count = frames.len(), "Synced queued mesh messages");
}
false
}
Err(e) => {
debug!("Message sync: {}", e);
true
}
}
}
@@ -654,3 +675,539 @@ async fn refresh_contacts(
}
}
}
// ─── Typed Message Dispatch ────────────────────────────────────────────
/// Try to base64-decode payload and check if the result is a typed envelope.
/// Returns the decoded bytes if it's a valid base64-encoded TypedEnvelope.
fn try_base64_typed(payload: &[u8]) -> Option<Vec<u8>> {
use base64::Engine;
// Quick check: base64 starts with uppercase letters or digits, not 0x02
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()?;
if TypedEnvelope::is_typed(&decoded) {
Some(decoded)
} else {
None
}
}
/// Look up a peer by pubkey hex prefix. Returns (contact_id, display_name).
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.
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 typed message envelope (0x02 prefix).
/// Dispatches to type-specific handlers: BlockHeader, Alert, TxRelay, etc.
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;
}
};
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) => {
// Compact binary format: height(8) + hash(32) + timestamp(4)
match super::bitcoin_relay::decode_compact_block_header(&envelope.v) {
Ok((height, hash_hex, _timestamp)) => {
info!(
height,
hash = %hash_hex,
"Block header received via mesh"
);
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),
}
}
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) => {
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) => {
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,
"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 {
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;
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),
}
}
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) => {
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;
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),
}
}
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"
);
}
}
}
/// 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));
}
// ─── TX Relay Broadcast + Confirmation Tracking ────────────────────────
/// 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.
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;
}
};
// Step 1: Broadcast via Bitcoin Core RPC sendrawtransaction
let body = serde_json::json!({
"jsonrpc": "1.0",
"id": "mesh-relay",
"method": "sendrawtransaction",
"params": [relay.tx_hex]
});
let txid = match client
.post("http://127.0.0.1:8332/")
.basic_auth("archipelago", Some("archipelago123"))
.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 msg = err.get("message").and_then(|m| m.as_str()).unwrap_or("unknown");
warn!(request_id = relay.request_id, "sendrawtransaction failed: {}", msg);
send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some(msg)).await;
return;
}
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("RPC parse error")).await;
return;
}
}
}
Err(e) => {
warn!("Bitcoin Core RPC unreachable: {}", e);
send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some("No Bitcoin node available")).await;
return;
}
};
let Some(txid) = txid else {
send_tx_relay_response(state, sender_contact_id, relay.request_id, None, Some("No txid returned")).await;
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).await;
// Step 3: Monitor confirmations (poll every 30s, up to 3 hours)
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, relay.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>,
) {
let wire = match super::bitcoin_relay::build_tx_relay_response(request_id, txid, error) {
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;
}
}
}
/// Send raw wire bytes to a specific peer by contact_id.
/// Falls back to channel 0 broadcast if peer's pubkey is unknown.
async fn send_to_peer(state: &Arc<MeshState>, contact_id: u32, payload: 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);
let _ = state.cmd_tx.send(MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload,
}).await;
return;
}
}
}
}
drop(peers);
let _ = state.cmd_tx.send(MeshCommand::BroadcastChannel {
channel: 0,
payload,
}).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 resp = client
.post("http://127.0.0.1:8332/")
.basic_auth("archipelago", Some("archipelago123"))
.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")
}
}

View File

@@ -30,6 +30,8 @@ pub enum MeshMessageType {
TxRelayResponse = 9,
LightningRelay = 10,
LightningRelayResponse = 11,
/// Confirmation update for a relayed transaction (1, 2, 3 confs).
TxConfirmation = 12,
}
impl MeshMessageType {
@@ -47,6 +49,7 @@ impl MeshMessageType {
9 => Some(Self::TxRelayResponse),
10 => Some(Self::LightningRelay),
11 => Some(Self::LightningRelayResponse),
12 => Some(Self::TxConfirmation),
_ => None,
}
}
@@ -65,6 +68,7 @@ impl MeshMessageType {
Self::TxRelayResponse => "tx_relay_response",
Self::LightningRelay => "lightning_relay",
Self::LightningRelayResponse => "lightning_relay_response",
Self::TxConfirmation => "tx_confirmation",
}
}
}
@@ -294,6 +298,18 @@ pub struct LightningRelayResponsePayload {
pub error: Option<String>,
}
/// Transaction confirmation update (relay node → originator).
/// Sent after each new confirmation (1, 2, 3) until fully confirmed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TxConfirmationPayload {
pub request_id: u64,
pub txid: String,
/// Number of confirmations (1, 2, 3).
pub confirmations: u32,
/// Block height where the transaction was included.
pub block_height: u64,
}
// ─── Helpers ────────────────────────────────────────────────────────────
/// Encode a payload type to CBOR bytes.

View File

@@ -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::*;

View File

@@ -408,6 +408,78 @@ pub fn parse_channel_msg_v1(data: &[u8]) -> Result<(u8, String)> {
Ok((channel_idx, text))
}
// ─── Raw-bytes variants for typed message detection ────────────────────
/// Parse RESP_CONTACT_MSG_V3 returning raw payload bytes (not UTF-8 lossy).
/// Returns (sender_pubkey_prefix_hex, raw_payload_bytes, snr).
pub fn parse_contact_msg_v3_raw(data: &[u8]) -> Result<(String, Vec<u8>, i8)> {
if data.len() < 15 {
anyhow::bail!("Contact message too short: {} bytes", data.len());
}
let snr = data[0] as i8;
let pubkey_prefix = hex::encode(&data[3..9]);
let txt_type = data[10];
let text_start = if txt_type == 2 { 19 } else { 15 };
let payload = if data.len() > text_start {
data[text_start..].to_vec()
} else {
Vec::new()
};
Ok((pubkey_prefix, payload, snr))
}
/// Parse RESP_CONTACT_MSG returning raw payload bytes.
/// Returns (sender_pubkey_prefix_hex, raw_payload_bytes).
pub fn parse_contact_msg_v1_raw(data: &[u8]) -> Result<(String, Vec<u8>)> {
if data.len() < 12 {
anyhow::bail!("Contact message v1 too short: {} bytes", data.len());
}
let pubkey_prefix = hex::encode(&data[0..6]);
let txt_type = data[7];
let text_start = if txt_type == 2 { 16 } else { 12 };
let payload = if data.len() > text_start {
data[text_start..].to_vec()
} else {
Vec::new()
};
Ok((pubkey_prefix, payload))
}
/// Parse RESP_CHANNEL_MSG_V3 returning raw payload bytes.
/// Returns (channel_idx, raw_payload_bytes).
pub fn parse_channel_msg_v3_raw(data: &[u8]) -> Result<(u8, Vec<u8>)> {
if data.len() < 7 {
anyhow::bail!("Channel message too short: {} bytes", data.len());
}
let channel_idx = data[0];
let payload = if data.len() > 7 {
let mut p = data[7..].to_vec();
// Strip trailing NUL bytes
while p.last() == Some(&0) { p.pop(); }
p
} else {
Vec::new()
};
Ok((channel_idx, payload))
}
/// Parse RESP_CHANNEL_MSG returning raw payload bytes.
/// Returns (channel_idx, raw_payload_bytes).
pub fn parse_channel_msg_v1_raw(data: &[u8]) -> Result<(u8, Vec<u8>)> {
if data.len() < 7 {
anyhow::bail!("Channel message v1 too short: {} bytes", data.len());
}
let channel_idx = data[0];
let payload = if data.len() > 7 {
let mut p = data[7..].to_vec();
while p.last() == Some(&0) { p.pop(); }
p
} else {
Vec::new()
};
Ok((channel_idx, payload))
}
/// Parse RESP_ERR (0x01). Returns descriptive error string.
pub fn parse_error(data: &[u8]) -> String {
if data.is_empty() {

View File

@@ -111,4 +111,12 @@ pub enum MeshEvent {
pubkey_hex: String,
x25519_pubkey: [u8; 32],
},
/// Block header received from an internet-connected mesh peer.
BlockHeaderReceived { height: u64, hash: String },
/// Emergency or dead-man alert received from a peer.
AlertReceived { alert_type: String, message: String, from_contact_id: u32 },
/// TX relay completed (response received from internet peer).
TxRelayCompleted { request_id: u64, txid: Option<String>, error: Option<String> },
/// Lightning relay completed (response received from internet peer).
LightningRelayCompleted { request_id: u64, payment_hash: Option<String>, error: Option<String> },
}

View File

@@ -53,7 +53,8 @@ export interface MeshMessage {
delivered: boolean
encrypted: boolean
message_type?: MeshMessageTypeLabel
typed_payload?: InvoiceData | AlertData | CoordinateData | null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
typed_payload?: Record<string, any> | null
}
export interface InvoiceData {
@@ -94,6 +95,14 @@ export interface AlertStatus {
emergency_contacts: number
}
export interface BlockHeader {
height: number
hash: string
prev_hash: string
timestamp: number
announced_by: string
}
export const useMeshStore = defineStore('mesh', () => {
const status = ref<MeshStatus | null>(null)
const peers = ref<MeshPeer[]>([])
@@ -269,8 +278,71 @@ export const useMeshStore = defineStore('mesh', () => {
})
}
// ─── Phase 4: Off-Grid Bitcoin Operations ────────────────────────────
const deadmanStatus = ref<AlertStatus | null>(null)
const blockHeaders = ref<BlockHeader[]>([])
const latestBlockHeight = ref(0)
async function fetchDeadmanStatus() {
try {
deadmanStatus.value = await rpcClient.call<AlertStatus>({ method: 'mesh.deadman-status' })
} catch {
// Dead man switch not available
}
}
async function configureDeadman(config: {
enabled?: boolean
interval_secs?: number
lat?: number
lng?: number
label?: string
contacts?: string[]
custom_message?: string
auto_gps?: boolean
}) {
return rpcClient.call<AlertStatus>({
method: 'mesh.deadman-configure',
params: config,
})
}
async function deadmanCheckin() {
return rpcClient.call<{ checked_in: boolean; time_remaining_secs: number }>({
method: 'mesh.deadman-checkin',
})
}
async function fetchBlockHeaders(count = 10) {
try {
const res = await rpcClient.call<{ headers: BlockHeader[]; latest_height: number; count: number }>({
method: 'mesh.block-headers',
params: { count },
})
blockHeaders.value = res.headers
latestBlockHeight.value = res.latest_height
} catch {
// Block headers not available
}
}
async function relayTransaction(txHex: string) {
return rpcClient.call<{ request_id: number; queued: boolean; tx_hex_len: number }>({
method: 'mesh.relay-tx',
params: { tx_hex: txHex },
})
}
async function relayLightning(bolt11: string, amountSats: number) {
return rpcClient.call<{ request_id: number; queued: boolean; amount_sats: number }>({
method: 'mesh.relay-lightning',
params: { bolt11, amount_sats: amountSats },
})
}
async function refreshAll() {
await Promise.all([fetchStatus(), fetchPeers(), fetchMessages()])
await Promise.all([fetchStatus(), fetchPeers(), fetchMessages(), fetchDeadmanStatus(), fetchBlockHeaders()])
}
return {
@@ -282,6 +354,9 @@ export const useMeshStore = defineStore('mesh', () => {
sending,
unreadCounts,
totalUnread,
deadmanStatus,
blockHeaders,
latestBlockHeight,
fetchStatus,
fetchPeers,
fetchMessages,
@@ -296,5 +371,11 @@ export const useMeshStore = defineStore('mesh', () => {
sendAlert,
getSessionStatus,
rotatePrekeys,
fetchDeadmanStatus,
configureDeadman,
deadmanCheckin,
fetchBlockHeaders,
relayTransaction,
relayLightning,
}
})

View File

@@ -4,6 +4,7 @@ import { useMeshStore } from '@/stores/mesh'
import { useTransportStore } from '@/stores/transport'
import type { MeshPeer, SessionStatus } from '@/stores/mesh'
import AnimatedLogo from '@/components/AnimatedLogo.vue'
import { rpcClient } from '@/api/rpc-client'
const mesh = useMeshStore()
const transport = useTransportStore()
@@ -24,6 +25,21 @@ const publicChannel = { index: 0, name: 'Public' }
const togglingOffGrid = ref(false)
const peerSessionInfo = ref<SessionStatus | null>(null)
// Phase 4: Off-grid Bitcoin + Dead Man's Switch
const activeTab = ref<'chat' | 'bitcoin' | 'deadman'>('chat')
const txHexInput = ref('')
const bolt11Input = ref('')
const bolt11AmountInput = ref('')
const relayingTx = ref(false)
const relayingLn = ref(false)
const relayResult = ref('')
const meshSendAddr = ref('')
const meshSendAmount = ref('')
const deadmanConfiguring = ref(false)
const deadmanInterval = ref('21600')
const deadmanEnabled = ref(false)
const deadmanCustomMsg = ref('')
// Fetch session status when active peer changes
watch(() => activeChatPeer.value, async (peer) => {
if (peer) {
@@ -44,12 +60,111 @@ async function handleToggleOffGrid() {
} finally { togglingOffGrid.value = false }
}
async function handleMeshSendBitcoin() {
if (!meshSendAddr.value.trim() || !meshSendAmount.value) return
relayingTx.value = true
relayResult.value = ''
try {
// Step 1: Create signed raw TX locally (no broadcast)
relayResult.value = 'Creating signed transaction...'
const rawRes = await rpcClient.call<{ raw_tx_hex: string; amount_sats: number }>({
method: 'lnd.create-raw-tx',
params: { addr: meshSendAddr.value.trim(), amount_sats: parseInt(meshSendAmount.value) },
})
// Step 2: Relay via mesh
relayResult.value = 'Sending via mesh radio...'
const relayRes = await mesh.relayTransaction(rawRes.raw_tx_hex)
relayResult.value = `Sent via mesh! Request #${relayRes.request_id} — waiting for broadcast confirmation from peers`
meshSendAddr.value = ''
meshSendAmount.value = ''
} catch (err: unknown) {
relayResult.value = err instanceof Error ? err.message : 'Send failed'
} finally {
relayingTx.value = false
}
}
async function handleRelayTx() {
if (!txHexInput.value.trim()) return
relayingTx.value = true
relayResult.value = ''
try {
const res = await mesh.relayTransaction(txHexInput.value.trim())
relayResult.value = `TX queued (request #${res.request_id})`
txHexInput.value = ''
} catch (err: unknown) {
relayResult.value = err instanceof Error ? err.message : 'Relay failed'
} finally {
relayingTx.value = false
}
}
async function handleRelayLightning() {
if (!bolt11Input.value.trim() || !bolt11AmountInput.value) return
relayingLn.value = true
relayResult.value = ''
try {
const res = await mesh.relayLightning(bolt11Input.value.trim(), parseInt(bolt11AmountInput.value))
relayResult.value = `Lightning relay queued (request #${res.request_id})`
bolt11Input.value = ''
bolt11AmountInput.value = ''
} catch (err: unknown) {
relayResult.value = err instanceof Error ? err.message : 'Relay failed'
} finally {
relayingLn.value = false
}
}
async function handleDeadmanToggle() {
// Instant enable/disable without waiting for full save
deadmanConfiguring.value = true
try {
await mesh.configureDeadman({ enabled: deadmanEnabled.value })
await mesh.fetchDeadmanStatus()
} finally {
deadmanConfiguring.value = false
}
}
async function handleDeadmanConfigure() {
deadmanConfiguring.value = true
try {
await mesh.configureDeadman({
enabled: deadmanEnabled.value,
interval_secs: parseInt(deadmanInterval.value) || 21600,
custom_message: deadmanCustomMsg.value || undefined,
})
await mesh.fetchDeadmanStatus()
} finally {
deadmanConfiguring.value = false
}
}
async function handleDeadmanCheckin() {
await mesh.deadmanCheckin()
await mesh.fetchDeadmanStatus()
}
function formatTimeRemaining(secs: number): string {
if (secs >= 86400) return `${Math.floor(secs / 3600)}h`
if (secs >= 3600) return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`
if (secs >= 60) return `${Math.floor(secs / 60)}m ${secs % 60}s`
return `${secs}s`
}
onMounted(async () => {
await Promise.all([mesh.refreshAll(), transport.fetchStatus()])
// Sync deadman UI state from server
if (mesh.deadmanStatus) {
deadmanEnabled.value = mesh.deadmanStatus.dead_man_enabled
deadmanInterval.value = String(mesh.deadmanStatus.dead_man_interval_secs)
}
pollInterval = setInterval(() => {
mesh.fetchStatus()
mesh.fetchPeers()
mesh.fetchMessages()
mesh.fetchDeadmanStatus()
mesh.fetchBlockHeaders()
}, 5000)
})
@@ -181,8 +296,8 @@ function truncatePubkey(hex: string | null): string {
<template>
<div class="mesh-view">
<!-- Header -->
<div class="mesh-header">
<!-- Header (hidden on mobile title is in the tab bar) -->
<div class="mesh-header hidden md:flex">
<div class="mesh-header-left">
<h1 class="mesh-title">Mesh Network</h1>
<p class="mesh-subtitle">
@@ -333,9 +448,141 @@ function truncatePubkey(hex: string | null): string {
</div>
</div>
<!-- RIGHT COLUMN: Chat panel -->
<!-- RIGHT COLUMN: Tabbed panels -->
<div class="mesh-right">
<div class="glass-card mesh-chat-card">
<!-- Tab bar -->
<div class="mesh-tab-bar">
<button class="mesh-tab" :class="{ active: activeTab === 'chat' }" @click="activeTab = 'chat'">Chat</button>
<button class="mesh-tab" :class="{ active: activeTab === 'bitcoin' }" @click="activeTab = 'bitcoin'">
Off-Grid Bitcoin
<span v-if="mesh.latestBlockHeight > 0" class="mesh-tab-badge">{{ mesh.latestBlockHeight }}</span>
</button>
<button class="mesh-tab" :class="{ active: activeTab === 'deadman' }" @click="activeTab = 'deadman'">
Dead Man
<span v-if="mesh.deadmanStatus?.triggered" class="mesh-tab-badge mesh-tab-badge-alert">!</span>
</button>
</div>
<!-- Off-Grid Bitcoin Panel -->
<div v-if="activeTab === 'bitcoin'" class="glass-card mesh-bitcoin-panel">
<h3 class="mesh-panel-title">Off-Grid Bitcoin</h3>
<p class="mesh-panel-sub">Relay transactions and receive block headers via mesh radio</p>
<!-- Block Headers -->
<div class="mesh-bitcoin-section">
<div class="mesh-bitcoin-section-header">
<span class="mesh-bitcoin-label">Latest Block</span>
<span v-if="mesh.latestBlockHeight > 0" class="mesh-bitcoin-height">#{{ mesh.latestBlockHeight.toLocaleString() }}</span>
<span v-else class="mesh-bitcoin-height mesh-muted">No headers yet</span>
</div>
<div v-if="mesh.blockHeaders.length" class="mesh-block-list">
<div v-for="h in mesh.blockHeaders.slice(0, 2)" :key="h.height" class="mesh-block-row">
<span class="mesh-block-height">#{{ h.height.toLocaleString() }}</span>
<span class="mesh-block-hash">{{ h.hash.slice(0, 12) }}...{{ h.hash.slice(-8) }}</span>
</div>
</div>
</div>
<!-- Send Bitcoin (creates TX + auto-relays) -->
<div class="mesh-bitcoin-section">
<div class="mesh-bitcoin-section-header">
<span class="mesh-bitcoin-label">Send Bitcoin (Off-Grid)</span>
</div>
<p class="mesh-bitcoin-hint">Creates a signed transaction locally and relays via mesh peers</p>
<input v-model="meshSendAddr" class="mesh-bitcoin-input" placeholder="Bitcoin address (bc1...)" />
<input v-model="meshSendAmount" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" min="546" />
<button class="glass-button" :disabled="!meshSendAddr.trim() || !meshSendAmount || relayingTx" @click="handleMeshSendBitcoin">
{{ relayingTx ? 'Sending...' : 'Send Bitcoin via Mesh' }}
</button>
</div>
<!-- Send Lightning (auto-relays invoice) -->
<div class="mesh-bitcoin-section">
<div class="mesh-bitcoin-section-header">
<span class="mesh-bitcoin-label">Pay Lightning Invoice (Off-Grid)</span>
</div>
<p class="mesh-bitcoin-hint">Relays a Lightning invoice to an internet-connected peer for payment</p>
<input v-model="bolt11Input" class="mesh-bitcoin-input" placeholder="lnbc... (bolt11 invoice)" />
<input v-model="bolt11AmountInput" class="mesh-bitcoin-input mesh-bitcoin-input-sm" type="number" placeholder="Amount (sats)" />
<button class="glass-button" :disabled="!bolt11Input.trim() || !bolt11AmountInput || relayingLn" @click="handleRelayLightning">
{{ relayingLn ? 'Relaying...' : 'Pay via Mesh' }}
</button>
</div>
<!-- Advanced: Raw TX Relay -->
<details class="mesh-bitcoin-advanced">
<summary class="mesh-bitcoin-label">Advanced: Raw TX Relay</summary>
<div class="mesh-bitcoin-section" style="margin-top: 8px;">
<textarea v-model="txHexInput" class="mesh-bitcoin-input" placeholder="Paste raw transaction hex..." rows="3" />
<button class="glass-button" :disabled="!txHexInput.trim() || relayingTx" @click="handleRelayTx">
{{ relayingTx ? 'Relaying...' : 'Relay Raw TX' }}
</button>
</div>
</details>
<div v-if="relayResult" class="mesh-relay-result" :class="relayResult.includes('failed') ? 'error' : 'success'">
{{ relayResult }}
</div>
</div>
<!-- Dead Man's Switch Panel -->
<div v-if="activeTab === 'deadman'" class="glass-card mesh-deadman-panel">
<h3 class="mesh-panel-title">Dead Man's Switch</h3>
<p class="mesh-panel-sub">Auto-broadcasts a signed emergency alert if you don't check in</p>
<!-- Status -->
<div v-if="mesh.deadmanStatus" class="mesh-deadman-status">
<div class="mesh-deadman-indicator" :class="mesh.deadmanStatus.triggered ? 'triggered' : mesh.deadmanStatus.dead_man_enabled ? 'armed' : 'disabled'">
{{ mesh.deadmanStatus.triggered ? 'TRIGGERED' : mesh.deadmanStatus.dead_man_enabled ? 'ARMED' : 'DISABLED' }}
</div>
<div v-if="mesh.deadmanStatus.dead_man_enabled && !mesh.deadmanStatus.triggered" class="mesh-deadman-timer">
{{ formatTimeRemaining(mesh.deadmanStatus.time_remaining_secs) }}
</div>
<div v-if="deadmanCustomMsg || mesh.deadmanStatus.dead_man_enabled" class="mesh-deadman-message">
{{ deadmanCustomMsg || 'Dead man\'s switch triggered operator unresponsive' }}
</div>
<button v-if="mesh.deadmanStatus.dead_man_enabled" class="glass-button mesh-deadman-checkin-btn" @click="handleDeadmanCheckin">
I'm OK Check In
</button>
</div>
<!-- Configuration -->
<div class="mesh-deadman-config">
<label class="mesh-deadman-toggle">
<input v-model="deadmanEnabled" type="checkbox" @change="handleDeadmanToggle" />
<span>{{ deadmanEnabled ? 'Dead Man\'s Switch Active' : 'Enable Dead Man\'s Switch' }}</span>
</label>
<template v-if="deadmanEnabled">
<div class="mesh-deadman-field">
<label class="mesh-bitcoin-label">Trigger Interval</label>
<select v-model="deadmanInterval" class="mesh-bitcoin-input mesh-bitcoin-input-sm">
<option value="3600">1 hour</option>
<option value="21600">6 hours</option>
<option value="43200">12 hours</option>
<option value="86400">24 hours</option>
</select>
</div>
<div class="mesh-deadman-field">
<label class="mesh-bitcoin-label">Alert Message</label>
<input v-model="deadmanCustomMsg" class="mesh-bitcoin-input" placeholder="Dead man's switch triggered — operator unresponsive" />
</div>
<div class="mesh-deadman-info">
<span v-if="mesh.deadmanStatus?.has_gps" class="mesh-deadman-info-item">GPS: included</span>
<span class="mesh-deadman-info-item">Contacts: {{ mesh.deadmanStatus?.emergency_contacts ?? 0 }}</span>
</div>
<button class="glass-button" :disabled="deadmanConfiguring" @click="handleDeadmanConfigure">
{{ deadmanConfiguring ? 'Saving...' : 'Save Configuration' }}
</button>
</template>
</div>
</div>
<!-- Chat Panel (existing) -->
<div v-if="activeTab === 'chat'" class="glass-card mesh-chat-card">
<!-- No chat selected -->
<div v-if="!hasActiveChat" class="mesh-chat-empty">
<div class="mesh-chat-empty-icon">&#x1F4E1;</div>
@@ -552,6 +799,7 @@ function truncatePubkey(hex: string | null): string {
min-height: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
/* ─── Status card ─── */
@@ -1086,7 +1334,7 @@ function truncatePubkey(hex: string | null): string {
@media (max-width: 768px) {
.mesh-view {
height: auto;
padding: 16px;
padding: 0;
}
.mesh-columns {
@@ -1158,4 +1406,203 @@ function truncatePubkey(hex: string | null): string {
/* Block header */
.typed-block_header { border-left: 3px solid #a855f7; }
.mesh-typed-block { display: flex; align-items: center; gap: 4px; color: #a855f7; font-size: 0.8rem; }
/* ─── Tab bar ─── */
.mesh-tab-bar {
display: flex;
gap: 2px;
background: rgba(0,0,0,0.3);
border-radius: 10px;
padding: 3px;
flex-shrink: 0;
}
.mesh-tab {
flex: 1;
padding: 8px 12px;
border: none;
background: transparent;
color: rgba(255,255,255,0.5);
font-size: 0.82rem;
font-weight: 500;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.mesh-tab:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }
.mesh-tab.active { color: #fff; background: rgba(255,255,255,0.1); }
.mesh-tab-badge {
font-size: 0.65rem;
background: rgba(251,146,60,0.2);
color: #fb923c;
padding: 1px 5px;
border-radius: 4px;
font-weight: 600;
}
.mesh-tab-badge-alert {
background: rgba(239,68,68,0.3);
color: #ef4444;
animation: pulse-alert 1.5s infinite;
}
@keyframes pulse-alert { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
/* ─── Panel shared ─── */
.mesh-panel-title { font-size: 1rem; font-weight: 600; color: rgba(255,255,255,0.9); margin: 0 0 4px; }
.mesh-panel-sub { font-size: 0.78rem; color: rgba(255,255,255,0.5); margin: 0 0 16px; }
.mesh-muted { color: rgba(255,255,255,0.4); }
/* ─── Bitcoin panel ─── */
.mesh-bitcoin-panel {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
flex: 1;
overflow-y: auto;
}
.mesh-bitcoin-section {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: rgba(0,0,0,0.2);
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.06);
}
.mesh-bitcoin-section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.mesh-bitcoin-label { font-size: 0.78rem; color: rgba(255,255,255,0.6); font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; }
.mesh-bitcoin-height { font-size: 1.1rem; font-weight: 700; color: #fb923c; font-family: monospace; }
.mesh-bitcoin-input {
width: 100%;
padding: 8px 10px;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
color: rgba(255,255,255,0.9);
font-size: 0.82rem;
font-family: monospace;
resize: vertical;
}
.mesh-bitcoin-input::placeholder { color: rgba(255,255,255,0.3); }
.mesh-bitcoin-input:focus { outline: none; border-color: rgba(251,146,60,0.4); }
.mesh-bitcoin-input-sm { max-width: 200px; }
.mesh-block-list { display: flex; flex-direction: column; gap: 4px; }
.mesh-block-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
background: rgba(0,0,0,0.15);
border-radius: 6px;
font-size: 0.78rem;
}
.mesh-block-height { color: #fb923c; font-weight: 600; font-family: monospace; }
.mesh-block-hash { color: rgba(255,255,255,0.5); font-family: monospace; font-size: 0.72rem; }
.mesh-relay-result {
padding: 8px 12px;
border-radius: 8px;
font-size: 0.82rem;
}
.mesh-relay-result.success { background: rgba(74,222,128,0.1); color: #4ade80; border: 1px solid rgba(74,222,128,0.2); }
.mesh-relay-result.error { background: rgba(239,68,68,0.1); color: #ef4444; border: 1px solid rgba(239,68,68,0.2); }
/* ─── Dead Man panel ─── */
.mesh-deadman-panel {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
flex: 1;
overflow-y: auto;
}
.mesh-deadman-status {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 20px;
background: rgba(0,0,0,0.2);
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.06);
}
.mesh-deadman-indicator {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 1px;
padding: 6px 16px;
border-radius: 20px;
}
.mesh-deadman-indicator.disabled { color: rgba(255,255,255,0.4); background: rgba(255,255,255,0.05); }
.mesh-deadman-indicator.armed { color: #4ade80; background: rgba(74,222,128,0.12); border: 1px solid rgba(74,222,128,0.3); }
.mesh-deadman-indicator.triggered { color: #ef4444; background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.4); animation: pulse-alert 1.5s infinite; }
.mesh-deadman-timer { font-size: 1.5rem; font-weight: 700; color: rgba(255,255,255,0.8); font-family: monospace; }
.mesh-deadman-checkin-btn { padding: 10px 24px; font-size: 0.9rem; font-weight: 600; }
.mesh-deadman-config {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
background: rgba(0,0,0,0.2);
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.06);
}
.mesh-deadman-toggle {
display: flex;
align-items: center;
gap: 8px;
color: rgba(255,255,255,0.8);
font-size: 0.85rem;
cursor: pointer;
}
.mesh-deadman-toggle input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #fb923c;
}
.mesh-deadman-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.mesh-deadman-info {
display: flex;
gap: 12px;
}
.mesh-deadman-info-item {
font-size: 0.75rem;
color: rgba(255,255,255,0.4);
background: rgba(255,255,255,0.05);
padding: 2px 8px;
border-radius: 4px;
}
.mesh-deadman-message {
font-size: 0.8rem;
color: rgba(255,255,255,0.5);
font-style: italic;
text-align: center;
padding: 0 12px;
}
.mesh-bitcoin-hint {
font-size: 0.72rem;
color: rgba(255,255,255,0.4);
margin: -4px 0 4px;
}
.mesh-bitcoin-advanced {
margin-top: 4px;
}
.mesh-bitcoin-advanced summary {
cursor: pointer;
padding: 8px 0;
color: rgba(255,255,255,0.4);
}
.mesh-bitcoin-advanced summary:hover {
color: rgba(255,255,255,0.6);
}
</style>

View File

@@ -1613,6 +1613,31 @@
</div>
</div>
<!-- Mesh Relay Prompt shown when offline -->
<div v-if="showMeshRelayPrompt" class="mb-3 p-4 bg-orange-500/10 border border-orange-500/30 rounded-lg">
<div class="flex items-center gap-2 mb-2">
<span class="text-lg">&#x1F4E1;</span>
<p class="text-orange-300 text-sm font-medium">You are offline</p>
</div>
<p class="text-white/70 text-xs mb-3">Send this transaction via mesh radio? It will be relayed by the nearest internet-connected node and you'll receive confirmation updates.</p>
<div class="flex gap-2">
<button @click="dismissMeshRelayPrompt" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs">Cancel</button>
<button @click="handleMeshRelaySend" class="flex-1 glass-button px-3 py-2 rounded-lg text-xs font-medium bg-orange-500/20 border-orange-500/30">Send via Mesh</button>
</div>
</div>
<!-- Mesh Relay Status -->
<div v-if="meshRelayActive" class="mb-3 p-3 bg-orange-500/10 border border-orange-500/20 rounded-lg">
<div class="flex items-center gap-2 mb-1">
<svg class="animate-spin h-3 w-3 text-orange-400" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-orange-300 text-xs font-medium">Mesh Relay</p>
</div>
<p class="text-white/60 text-xs">{{ meshRelayStatus }}</p>
</div>
<!-- On-chain txid result -->
<div v-if="sendResultTxid" class="mb-3 p-2 bg-green-500/10 border border-green-500/20 rounded-lg">
<p class="text-green-400 text-xs">Sent! TX: {{ sendResultTxid }}</p>
@@ -2077,11 +2102,15 @@ import { useWeb5BadgeStore } from '@/stores/web5Badge'
import { useAppStore } from '@/stores/app'
import { PackageState } from '@/types/api'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { useTransportStore } from '@/stores/transport'
import { useMeshStore } from '@/stores/mesh'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const messageToast = useMessageToast()
const transportStore = useTransportStore()
const meshStore = useMeshStore()
const web5Badge = useWeb5BadgeStore()
const appStore = useAppStore()
@@ -2727,6 +2756,11 @@ const unifiedSendError = ref('')
const sendResultTxid = ref('')
const sendResultHash = ref('')
const useHardwareWallet = ref(false)
// Mesh relay state
const meshRelayActive = ref(false)
const meshRelayStatus = ref('')
const meshRelayRequestId = ref(0)
const showMeshRelayPrompt = ref(false)
const psbtData = ref('')
const psbtStep = ref<'idle' | 'created' | 'finalizing'>('idle')
const signedPsbtInput = ref('')
@@ -2795,6 +2829,8 @@ async function unifiedSend() {
ecashSendToken.value = ''
sendResultTxid.value = ''
sendResultHash.value = ''
meshRelayActive.value = false
meshRelayStatus.value = ''
const method = effectiveSendMethod.value
try {
@@ -2830,11 +2866,32 @@ async function unifiedSend() {
unifiedSendProcessing.value = false
return
}
const res = await rpcClient.call<{ txid: string }>({
method: 'lnd.sendcoins',
params: { addr: unifiedSendDest.value.trim(), amount: unifiedSendAmount.value },
})
sendResultTxid.value = res.txid
// Check if we're offline/mesh-only offer mesh relay
await transportStore.fetchStatus()
if (transportStore.meshOnly) {
showMeshRelayPrompt.value = true
unifiedSendProcessing.value = false
return
}
// Normal online send
try {
const res = await rpcClient.call<{ txid: string }>({
method: 'lnd.sendcoins',
params: { addr: unifiedSendDest.value.trim(), amount: unifiedSendAmount.value },
})
sendResultTxid.value = res.txid
} catch (sendErr: unknown) {
// If send fails (possibly due to network), offer mesh relay as fallback
const errMsg = sendErr instanceof Error ? sendErr.message : ''
if (errMsg.includes('connection') || errMsg.includes('timeout') || errMsg.includes('unavailable')) {
showMeshRelayPrompt.value = true
unifiedSendProcessing.value = false
return
}
throw sendErr
}
}
await loadEcashBalance()
await loadLndBalances()
@@ -2845,6 +2902,92 @@ async function unifiedSend() {
}
}
async function handleMeshRelaySend() {
showMeshRelayPrompt.value = false
unifiedSendProcessing.value = true
meshRelayActive.value = true
meshRelayStatus.value = 'Creating signed transaction...'
unifiedSendError.value = ''
try {
// Step 1: Create a signed raw TX without broadcasting
meshRelayStatus.value = 'Signing transaction locally...'
const rawRes = await rpcClient.call<{ raw_tx_hex: string; amount_sats: number }>({
method: 'lnd.create-raw-tx',
params: {
addr: unifiedSendDest.value.trim(),
amount_sats: unifiedSendAmount.value,
},
})
// Step 2: Relay via mesh
meshRelayStatus.value = 'Sending via mesh radio to connected peers...'
const relayRes = await meshStore.relayTransaction(rawRes.raw_tx_hex)
meshRelayRequestId.value = relayRes.request_id
meshRelayStatus.value = 'Transaction sent via mesh — waiting for broadcast confirmation...'
// Step 3: Poll for relay response (check mesh messages for result)
startMeshRelayPolling(relayRes.request_id)
} catch (err: unknown) {
meshRelayActive.value = false
meshRelayStatus.value = ''
unifiedSendError.value = err instanceof Error ? err.message : 'Mesh relay failed'
} finally {
unifiedSendProcessing.value = false
}
}
function dismissMeshRelayPrompt() {
showMeshRelayPrompt.value = false
}
let meshRelayPollTimer: ReturnType<typeof setInterval> | null = null
function startMeshRelayPolling(_requestId: number) {
// Poll mesh messages every 5s to check for relay response / confirmations
if (meshRelayPollTimer) clearInterval(meshRelayPollTimer)
meshRelayPollTimer = setInterval(async () => {
await meshStore.fetchMessages()
const msgs = meshStore.messages
// Look for relay response or confirmation for our request
for (const msg of msgs) {
if (msg.direction !== 'received') continue
const text = msg.plaintext
if (text.includes(`[tx_relay_response]`) && text.includes('txid:')) {
// TX was broadcast! Extract txid
const match = text.match(/txid:\s*(\w+)/)
if (match && match[1]) {
sendResultTxid.value = match[1]
meshRelayStatus.value = `Broadcast confirmed! txid: ${match[1].slice(0, 16)}... — waiting for confirmations`
}
}
if (text.includes('[tx_confirmation]')) {
const confMatch = text.match(/(\d)\/3 confirmations/)
if (confMatch && confMatch[1]) {
const confs = parseInt(confMatch[1])
meshRelayStatus.value = `${confs}/3 confirmations${confs >= 3 ? ' — Transaction confirmed!' : '...'}`
if (confs >= 3) {
meshRelayActive.value = false
if (meshRelayPollTimer) {
clearInterval(meshRelayPollTimer)
meshRelayPollTimer = null
}
await loadLndBalances()
}
}
}
}
}, 5000)
// Stop polling after 3 hours max
setTimeout(() => {
if (meshRelayPollTimer) {
clearInterval(meshRelayPollTimer)
meshRelayPollTimer = null
}
}, 3 * 60 * 60 * 1000)
}
async function finalizePsbt() {
if (!signedPsbtInput.value.trim() || unifiedSendProcessing.value) return
unifiedSendProcessing.value = true