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:
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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">📡</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>
|
||||
|
||||
@@ -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">📡</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
|
||||
|
||||
Reference in New Issue
Block a user