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

@@ -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>