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
co-authored by Claude Opus 4.6
parent 4b7c765cd1
commit d1ac098edb
13 changed files with 2091 additions and 126 deletions
+32 -13
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 {