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 eeb3c77e12
commit 70f1348c15
13 changed files with 2091 additions and 126 deletions
+91
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> {