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

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