Merge main into archy-hwconfig — reconcile probe/dedup/name work

Both sides independently fixed the serial-alias dedup and the ESP32
boot-reset races; kept the branch's defer-to-auto-detect for unpinned
preferred paths (single probe pass per cycle) on top of main's
advert-name threading, Reticulum name propagation and radio-first
routing. Modal keeps main's 'Set Recommended' naming + probe progress
bar alongside the branch's in-app firmware flasher step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-28 19:03:19 -04:00
co-authored by Claude Fable 5
173 changed files with 10967 additions and 1925 deletions
@@ -525,10 +525,10 @@ async function approvePayment() {
receipt = { method: 'ecash', token: res.token, amount_sats: res.amount_sats }
} else if (method === 'lightning') {
if (pay.invoice) {
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
method: 'lnd.payinvoice',
params: { payment_request: pay.invoice },
})
// Tracked to a real terminal state — slow routing is not a failure.
const res = await rpcClient.payLightningInvoice({ payment_request: pay.invoice })
if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed')
if (res.status === 'pending') throw new Error('Payment is still settling — check your wallet transactions before retrying.')
receipt = { method: 'lightning', payment_hash: res.payment_hash, amount_sats: res.amount_sats }
} else {
// Create and immediately return an invoice for the requester to display
@@ -150,7 +150,7 @@ const STORAGE_KEY = 'neode_companion_intro_seen'
// exposes the release-server address.
const DEFAULT_DOWNLOAD_URL = IS_DEMO
? `${window.location.origin}/packages/archipelago-companion.apk`
: 'http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/neode-ui/public/packages/archipelago-companion.apk'
: 'http://146.59.87.168:2100/packages/archipelago-companion.apk'
// Deep-link scheme the companion app registers; carries the server entry the
// app should create (see docs/companion-pairing-qr.md for the contract).
@@ -267,12 +267,20 @@ function isTailnetIp(host: string): boolean {
* - a tailnet 100.x address (operator browsing over Tailscale — a scanned
* QR carried one of these on 2026-07-22 and the companion sat there
* dialing an IP the phone had no route to)
* - a .fips name or mesh ULA (operator browsing over the mesh — a scanned
* QR carried npub….fips as fhost on 2026-07-24; Android's system DNS
* can't resolve .fips, so the phone's direct dial died and first
* connect crawled through anchor discovery instead of the LAN)
*/
async function resolveServerUrl(): Promise<string> {
if (IS_DEMO) return DEMO_SERVER_URL
const { hostname, origin } = window.location
const phoneUnreachable =
hostname === 'localhost' || hostname === '127.0.0.1' || isTailnetIp(hostname)
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
isTailnetIp(hostname) ||
hostname.endsWith('.fips') ||
hostname.includes(':') // IPv6 literal — the node's mesh ULA
if (!phoneUnreachable) return origin
try {
const res = await rpcClient.call<{ mdns_hostname?: string; lan_ip?: string | null }>({
@@ -75,7 +75,7 @@
</div>
<!-- No Channels -->
<div v-else-if="channels.length === 0" key="empty" class="glass-card p-8 text-center">
<div v-else-if="channels.length === 0 && closedChannels.length === 0" key="empty" class="glass-card p-8 text-center">
<svg class="w-16 h-16 text-white/20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
@@ -85,6 +85,23 @@
<!-- Channel List -->
<div v-else key="channels" class="space-y-3">
<!-- Status tabs -->
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
<button
v-for="tab in tabs"
:key="tab.key"
@click="activeTab = tab.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors flex items-center justify-center gap-1.5"
:class="activeTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>
{{ tab.label }}
<span
class="px-1.5 py-0.5 rounded-full text-[10px] leading-none"
:class="activeTab === tab.key ? 'bg-white/15 text-white/80' : 'bg-white/10 text-white/40'"
>{{ tab.count }}</span>
</button>
</div>
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
@@ -96,7 +113,7 @@
{{ error }}
</div>
<div
v-for="ch in channels"
v-for="ch in filteredChannels"
:key="ch.chan_id || ch.channel_point"
class="glass-card p-4"
:class="{ 'bg-white/5': compact }"
@@ -109,12 +126,14 @@
'bg-green-400': channelStatus(ch) === 'active',
'bg-yellow-400': channelStatus(ch) === 'pending_open',
'bg-red-400': channelStatus(ch) === 'inactive',
'bg-gray-400': channelStatus(ch) === 'closing',
'bg-gray-500': channelStatus(ch) === 'force_closing',
}"
></span>
<span class="text-white/80 text-sm font-medium capitalize">{{ channelStatus(ch).replace('_', ' ') }}</span>
</div>
<button
v-if="channelStatus(ch) !== 'pending_open'"
v-if="!['pending_open', 'closing', 'force_closing'].includes(channelStatus(ch))"
@click="confirmClose(ch)"
class="text-red-400/70 hover:text-red-400 text-xs transition-colors"
>
@@ -148,9 +167,10 @@
</p>
</div>
<!-- Funding tx -->
<div v-if="fundingTxid(ch)" class="flex justify-end">
<!-- Funding / closing tx -->
<div v-if="fundingTxid(ch) || ch.closing_txid" class="flex justify-end gap-3">
<button
v-if="fundingTxid(ch)"
@click="openInMempool(fundingTxid(ch))"
class="flex items-center gap-1 text-blue-400/70 hover:text-blue-400 text-xs transition-colors"
:title="fundingTxid(ch)"
@@ -160,8 +180,66 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
<button
v-if="ch.closing_txid"
@click="openInMempool(ch.closing_txid!)"
class="flex items-center gap-1 text-orange-400/70 hover:text-orange-400 text-xs transition-colors"
:title="ch.closing_txid"
>
Closing tx in Mempool
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
</div>
</div>
<!-- Closed channel history (All + Closed tabs) -->
<div
v-for="ch in filteredClosed"
:key="'closed-' + (ch.chan_id || ch.channel_point || ch.closing_tx_hash)"
class="glass-card p-4 opacity-75"
:class="{ 'bg-white/5': compact }"
>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-white/30"></span>
<span class="text-white/60 text-sm font-medium">Closed</span>
<span v-if="closeTypeLabel(ch)" class="text-white/40 text-xs">· {{ closeTypeLabel(ch) }}</span>
</div>
<span v-if="ch.close_height" class="text-white/35 text-xs">Block {{ ch.close_height.toLocaleString() }}</span>
</div>
<p class="text-white/40 text-xs font-mono mb-3 truncate" :title="ch.remote_pubkey">
{{ ch.remote_pubkey }}
</p>
<div class="flex justify-between text-xs text-white/50 mb-2">
<span>Settled: {{ formatSats(ch.settled_balance) }}</span>
<span>Capacity: {{ formatSats(ch.capacity) }}</span>
</div>
<div v-if="ch.closing_tx_hash" class="flex justify-end">
<button
@click="openInMempool(ch.closing_tx_hash)"
class="flex items-center gap-1 text-blue-400/70 hover:text-blue-400 text-xs transition-colors"
:title="ch.closing_tx_hash"
>
Closing tx in Mempool
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
</div>
</div>
<!-- Per-tab empty state -->
<div
v-if="filteredChannels.length === 0 && filteredClosed.length === 0"
class="glass-card p-6 text-center"
>
<p class="text-white/50 text-sm">{{ emptyTabMessage }}</p>
</div>
</div>
</Transition>
@@ -299,8 +377,9 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import { useCachedResource } from '@/composables/useCachedResource'
import { useTxExplorer } from '@/composables/useTxExplorer'
defineProps<{ compact?: boolean }>()
@@ -314,6 +393,18 @@ interface Channel {
active: boolean
status?: string
channel_point?: string
closing_txid?: string
}
interface ClosedChannel {
chan_id?: string
remote_pubkey: string
capacity: number
settled_balance: number
close_type?: string
closing_tx_hash?: string
channel_point?: string
close_height?: number
}
/** Status with a fallback derived from `active` for backends that omit it */
@@ -321,6 +412,48 @@ function channelStatus(ch: Channel): string {
return ch.status ?? (ch.active ? 'active' : 'inactive')
}
type ChannelTab = 'all' | 'active' | 'pending' | 'closed'
const activeTab = ref<ChannelTab>('all')
/** pending_open, closing and force_closing all live on the Pending tab */
function isPendingState(ch: Channel): boolean {
return ['pending_open', 'closing', 'force_closing'].includes(channelStatus(ch))
}
const tabs = computed((): { key: ChannelTab; label: string; count: number }[] => [
{ key: 'all', label: 'All', count: channels.value.length + closedChannels.value.length },
{ key: 'active', label: 'Active', count: channels.value.filter(ch => !isPendingState(ch)).length },
{ key: 'pending', label: 'Pending', count: channels.value.filter(isPendingState).length },
{ key: 'closed', label: 'Closed', count: closedChannels.value.length },
])
const filteredChannels = computed((): Channel[] => {
switch (activeTab.value) {
case 'closed': return []
case 'active': return channels.value.filter(ch => !isPendingState(ch))
case 'pending': return channels.value.filter(isPendingState)
default: return channels.value
}
})
const filteredClosed = computed((): ClosedChannel[] =>
activeTab.value === 'all' || activeTab.value === 'closed' ? closedChannels.value : []
)
const emptyTabMessage = computed((): string => {
switch (activeTab.value) {
case 'active': return 'No open channels.'
case 'pending': return 'No pending or closing channels.'
case 'closed': return 'No closed channels yet.'
default: return 'No channels yet.'
}
})
/** "COOPERATIVE_CLOSE" / "cooperative_close" → "cooperative close" */
function closeTypeLabel(ch: ClosedChannel): string {
return (ch.close_type || '').toLowerCase().replace(/_/g, ' ')
}
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: number }[] = [
@@ -330,10 +463,41 @@ const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: n
{ key: 'custom', label: 'Custom' },
]
const loading = ref(true)
const error = ref<string | null>(null)
const channels = ref<Channel[]>([])
const summary = ref({ total_inbound: 0, total_outbound: 0 })
// Cached: revisits paint the channel lists instantly and revalidate behind
// them. Open and closed history are separate entries so a closed-history
// failure keeps its last list without touching the main channel view.
interface ChannelsData { channels: Channel[]; total_inbound: number; total_outbound: number }
const channelsRes = useCachedResource<ChannelsData>({
key: 'lnd.channels',
fetcher: async (signal) => {
const result = await rpcClient.call<ChannelsData>({
method: 'lnd.listchannels', timeout: 15000, signal, dedup: true, maxRetries: 1,
})
return {
channels: result?.channels || [],
total_inbound: result?.total_inbound || 0,
total_outbound: result?.total_outbound || 0,
}
},
})
const closedRes = useCachedResource<ClosedChannel[]>({
key: 'lnd.closed-channels',
fetcher: async (signal) => {
const closed = await rpcClient.call<{ channels: ClosedChannel[] }>({
method: 'lnd.closedchannels', timeout: 15000, signal, dedup: true, maxRetries: 1,
})
return closed?.channels || []
},
})
const loading = computed(() =>
channelsRes.loadState.value === 'loading' || channelsRes.loadState.value === 'refreshing')
const error = computed(() => channelsRes.error.value)
const channels = computed(() => channelsRes.data.value?.channels ?? [])
const closedChannels = computed(() => closedRes.data.value ?? [])
const summary = computed(() => ({
total_inbound: channelsRes.data.value?.total_inbound ?? 0,
total_outbound: channelsRes.data.value?.total_outbound ?? 0,
}))
// Olympus by ZEUS — the LSP node behind the Zeus mobile wallet.
// Channel limits: min 150,000 / max 1,500,000 sats.
@@ -405,26 +569,10 @@ function capacityPercent(amount: number, capacity: number): number {
return Math.round((amount / capacity) * 100)
}
async function loadChannels() {
const hadChannels = channels.value.length > 0
loading.value = true
error.value = null
try {
const result = await rpcClient.call<{ channels: Channel[]; total_inbound: number; total_outbound: number }>({
method: 'lnd.listchannels',
timeout: 15000,
})
channels.value = result.channels || []
summary.value = {
total_inbound: result.total_inbound || 0,
total_outbound: result.total_outbound || 0,
}
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to load channels'
if (!hadChannels) channels.value = []
} finally {
loading.value = false
}
function loadChannels(): Promise<void> {
const main = channelsRes.refresh()
void closedRes.refresh()
return main
}
function feeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
@@ -503,7 +651,5 @@ async function closeChannel() {
}
}
onMounted(loadChannels)
defineExpose({ channels, loadChannels })
</script>
+124
View File
@@ -0,0 +1,124 @@
<template>
<div>
<div class="flex gap-1 mb-3 p-1 bg-white/5 rounded-lg">
<button
v-for="tab in ([{ key: 'words', label: 'Words' }, { key: 'qr', label: 'QR code' }] as const)"
:key="tab.key"
type="button"
@click="seedTab = tab.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="seedTab === tab.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ tab.label }}</button>
</div>
<template v-if="seedTab === 'words'">
<p class="text-sm text-white/60 mb-3">Write these down and store them offline. Tap to {{ hidden ? 'reveal' : 'hide' }}.</p>
<div class="relative">
<div
class="grid grid-cols-2 sm:grid-cols-3 gap-2 p-3 bg-white/5 rounded-lg transition-all select-text"
:class="hidden ? 'blur-md' : ''"
@click="hidden = !hidden"
>
<div v-for="(w, i) in words" :key="i" class="flex items-center gap-1.5 text-sm">
<span class="text-white/30 text-xs w-5 text-right">{{ i + 1 }}.</span>
<span class="text-white font-mono">{{ w }}</span>
</div>
</div>
<button v-if="hidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="hidden = false">Tap to reveal</button>
</div>
</template>
<template v-else>
<p class="text-sm text-white/60 mb-3">
{{ aezeed ? 'Scan to copy the words into another device.' : 'Scan into a wallet that imports seeds by QR.' }}
Tap to {{ hidden ? 'reveal' : 'hide' }}.
</p>
<div class="relative">
<div
class="flex justify-center p-3 bg-white/5 rounded-lg transition-all"
:class="hidden ? 'blur-md' : ''"
@click="hidden = !hidden"
>
<canvas ref="qrCanvas" class="rounded-lg bg-white p-2"></canvas>
</div>
<button v-if="hidden" type="button" class="absolute inset-0 flex items-center justify-center text-xs text-white/70 font-medium" @click="hidden = false">Tap to reveal</button>
</div>
<div v-if="!aezeed && seedQrAvailable" class="flex justify-center mt-2">
<div class="flex p-0.5 bg-white/5 rounded-md">
<button
v-for="f in ([{ key: 'seedqr', label: 'SeedQR' }, { key: 'text', label: 'Plain text' }] as const)"
:key="f.key"
type="button"
@click="qrFormat = f.key"
class="px-2.5 py-1 rounded text-[11px] font-medium transition-colors"
:class="qrFormat === f.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ f.label }}</button>
</div>
</div>
<p class="text-xs text-white/40 mt-2">
<template v-if="aezeed">
The code contains your seed words as plain text treat it exactly like the words
themselves. Note: this is an LND <span class="font-mono">aezeed</span>, not a BIP39
phrase it restores into LND-based wallets (Zeus, Blixt, another Archipelago node),
not into hardware wallets like Passport.
</template>
<template v-else-if="qrFormat === 'seedqr'">
SeedQR scans into Passport, SeedSigner, Keystone and other wallets that import
seeds by QR. Treat this code exactly like the words themselves.
</template>
<template v-else>
Plain text words for wallets that read the phrase as text. Treat this code exactly
like the words themselves.
</template>
</p>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
// Shared seed reveal body: Words / QR code tabs behind a tap-to-reveal blur.
// Words are always the first view. For BIP39 seeds the QR defaults to the
// SeedQR standard (4-digit wordlist indices — what Passport Prime, SeedSigner,
// Keystone etc. import), with a plain-text option. `aezeed` seeds (LND) are
// NOT BIP39 and no hardware wallet can import them, so they only ever get the
// plain-text QR plus an explanation — SeedQR-encoding one would be dishonest.
const props = defineProps<{ words: string[]; aezeed?: boolean }>()
const seedTab = ref<'words' | 'qr'>('words')
const qrFormat = ref<'seedqr' | 'text'>(props.aezeed ? 'text' : 'seedqr')
const seedQrAvailable = ref(!props.aezeed)
const hidden = ref(true)
const qrCanvas = ref<HTMLCanvasElement | null>(null)
async function renderQr() {
await nextTick()
if (!qrCanvas.value || props.words.length === 0) return
try {
let payload = props.words.join(' ')
if (!props.aezeed && qrFormat.value === 'seedqr') {
const { toSeedQrDigits } = await import('@/utils/seedqr')
const digits = await toSeedQrDigits(props.words)
if (digits) {
payload = digits
} else {
// Not a BIP39 phrase after all — only plain text is honest.
seedQrAvailable.value = false
qrFormat.value = 'text'
return // the qrFormat watcher re-renders as text
}
}
const QRCode = await import('qrcode')
await QRCode.toCanvas(qrCanvas.value, payload, { width: 260, margin: 1 })
} catch { /* QR is a convenience — the words remain authoritative */ }
}
watch(seedTab, (t) => { if (t === 'qr') void renderQr() })
watch(qrFormat, () => { if (seedTab.value === 'qr') void renderQr() })
watch(() => props.words, () => {
seedTab.value = 'words' // fresh reveal always shows words first
hidden.value = true
seedQrAvailable.value = !props.aezeed
qrFormat.value = props.aezeed ? 'text' : 'seedqr'
})
</script>
+365 -49
View File
@@ -1,7 +1,58 @@
<template>
<BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<!-- ============ SUCCESS PANE the payment's moment, not a footnote ============ -->
<template v-if="successInfo">
<div class="text-center py-4">
<div class="send-success-burst mx-auto mb-6">
<span class="burst-ring"></span>
<span class="burst-ring burst-ring-2"></span>
<span class="burst-ring burst-ring-3"></span>
<div class="burst-core">
<svg class="w-14 h-14 text-green-400 burst-check" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
</div>
<div v-if="successInfo.amount > 0" class="text-5xl font-black text-green-400 mb-1">
{{ successInfo.amount.toLocaleString() }}<span class="text-2xl font-bold text-green-400/70"> sats</span>
</div>
<div class="text-2xl font-bold tracking-widest text-white mb-1">SENT</div>
<p class="text-sm text-white/50 mb-6">{{ successInfo.methodLabel }}</p>
<div v-if="successInfo.hash || successInfo.txid || successInfo.note" class="p-4 bg-white/5 rounded-xl text-left space-y-4 mb-6">
<div v-if="successInfo.hash">
<p class="text-xs text-white/50 mb-1">Payment hash</p>
<div class="flex items-center gap-2">
<p class="flex-1 text-xs font-mono text-white/80 break-all">{{ successInfo.hash }}</p>
<button
class="shrink-0 px-2.5 py-1.5 rounded-lg text-xs glass-button"
@click="copyDetail(successInfo.hash)"
>{{ copiedDetail === successInfo.hash ? 'Copied!' : 'Copy' }}</button>
</div>
</div>
<div v-if="successInfo.txid">
<p class="text-xs text-white/50 mb-1">Transaction ID</p>
<div class="flex items-center gap-2">
<p class="flex-1 text-xs font-mono text-white/80 break-all">{{ successInfo.txid }}</p>
<button
class="shrink-0 px-2.5 py-1.5 rounded-lg text-xs glass-button"
@click="copyDetail(successInfo.txid)"
>{{ copiedDetail === successInfo.txid ? 'Copied!' : 'Copy' }}</button>
</div>
</div>
<p v-if="successInfo.note" class="text-xs text-white/60">{{ successInfo.note }}</p>
</div>
<div class="flex gap-3">
<button @click="sendAnother" class="flex-1 glass-button px-4 py-3 rounded-xl text-sm font-medium">Send another</button>
<button @click="close" class="flex-1 glass-button glass-button-warning px-4 py-3 rounded-xl text-sm font-semibold">Done</button>
</div>
</div>
</template>
<!-- ============ CONFIRM PANE (second step, mirrors the scan flow) ============ -->
<template v-if="confirming">
<template v-else-if="confirming">
<div class="mb-3 p-3 bg-white/5 rounded-lg">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-white/50">Method</span>
@@ -27,6 +78,10 @@
</span>
<span class="text-sm font-medium text-white/80">{{ confirmAmount.toLocaleString() }} sats</span>
</div>
<div v-if="effectiveMethod === 'onchain'" class="flex items-center justify-between">
<span class="text-xs text-white/50">Network fee</span>
<span class="text-sm font-medium text-white/80">{{ feeEstimateLabel }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">Balance after</span>
<span class="text-sm font-medium" :class="insufficient ? 'text-red-400' : 'text-white/80'">
@@ -66,7 +121,19 @@
<div class="mb-3">
<div class="flex items-center justify-between mb-1">
<label class="text-white/60 text-sm">{{ t('sendBitcoin.amountSats') }}</label>
<div class="flex items-center gap-2">
<label class="text-white/60 text-sm">{{ amountLabel }}</label>
<!-- sats/BTC entry toggle (on-chain only) -->
<div v-if="sendMethod === 'onchain'" class="flex p-0.5 bg-white/5 rounded-md">
<button
v-for="u in (['sats', 'btc'] as const)"
:key="u"
@click="setAmountUnit(u)"
class="px-2 py-0.5 rounded text-[11px] font-medium transition-colors"
:class="amountUnit === u ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ u === 'btc' ? 'BTC' : 'sats' }}</button>
</div>
</div>
<span v-if="pastedInvoiceAmount !== null" class="text-[11px] px-2 py-0.5 rounded-full bg-white/10 text-white/50">set by invoice</span>
<button
v-if="sendMethod === 'onchain'"
@@ -80,10 +147,11 @@
</button>
</div>
<input
v-model.number="amount"
v-model.number="amountEntry"
type="number"
min="1"
:placeholder="sendAll ? '' : pastedInvoiceAmount !== null ? '' : '1000'"
:min="amountUnit === 'btc' && sendMethod === 'onchain' ? '0.00000001' : '1'"
:step="amountUnit === 'btc' && sendMethod === 'onchain' ? '0.00000001' : '1'"
:placeholder="sendAll ? '' : pastedInvoiceAmount !== null ? '' : amountUnit === 'btc' && sendMethod === 'onchain' ? '0.001' : '1000'"
:disabled="sendAll || pastedInvoiceAmount !== null"
class="w-full input-glass disabled:opacity-50"
/>
@@ -96,6 +164,7 @@
<p v-else-if="effectiveMethod === 'lightning' && dest.trim()" class="text-xs text-white/50 mt-1">
Zero-amount invoice — enter how many sats to pay.
</p>
<p v-else-if="unitConversionHint" class="text-xs text-white/40 mt-1">{{ unitConversionHint }}</p>
</div>
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
@@ -114,6 +183,48 @@
<textarea v-model="dest" rows="2" :placeholder="effectiveMethod === 'lightning' ? 'lnbc...' : effectiveMethod === 'ark' ? 'tark1 / lnbc / user@lnaddress' : 'bc1...'" class="w-full input-glass font-mono"></textarea>
</div>
<!-- Network fee (on-chain only) -->
<div v-if="sendMethod === 'onchain'" class="mb-3">
<label class="text-white/60 text-sm block mb-1">Network fee</label>
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
<button
v-for="preset in onchainFeePresets"
:key="preset.key"
@click="feePreset = preset.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="feePreset === preset.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ preset.label }}</button>
</div>
<p v-if="feePreset !== 'custom'" class="text-white/40 text-xs mt-1">
{{ onchainFeePresets.find(p => p.key === feePreset)?.hint }}
</p>
<div v-else class="grid grid-cols-2 gap-3 mt-2">
<div>
<label class="text-white/60 text-xs block mb-1">Target blocks</label>
<input
v-model.number="customConfTarget"
type="number"
min="1"
max="1008"
placeholder="6"
class="w-full input-glass"
/>
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Sats per vByte</label>
<input
v-model.number="customSatPerVbyte"
type="number"
min="1"
max="5000"
placeholder="—"
class="w-full input-glass"
/>
</div>
<p class="text-white/40 text-xs col-span-2">Set one — sats per vByte takes precedence when both are set</p>
</div>
</div>
<div v-if="ecashToken" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-white/50 text-xs mb-1">{{ t('sendBitcoin.tokenShareLabel') }}</p>
<!-- QR so the recipient can scan the token straight off this screen
@@ -125,16 +236,6 @@
<button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
</div>
<div v-if="resultTxid" class="mb-3 alert-success">
<p class="text-xs">{{ t('sendBitcoin.sentTx', { txid: resultTxid }) }}</p>
</div>
<div v-if="resultHash" class="mb-3 alert-success">
<p class="text-xs">{{ t('sendBitcoin.paidHash', { hash: resultHash }) }}</p>
</div>
<div v-if="resultArk" class="mb-3 alert-success">
<p class="text-xs">{{ resultArk }}</p>
</div>
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
<div class="flex gap-3">
@@ -167,13 +268,67 @@ const emit = defineEmits<{ close: []; sent: []; scan: [] }>()
// 'auto' remains in the type for the effectiveMethod logic but is no longer
// offered as a tab (hidden per operator request 2026-07-22).
const sendMethod = ref<'auto' | 'lightning' | 'onchain' | 'ecash' | 'fedimint' | 'ark'>('lightning')
const amount = ref<number>(0)
// --- Amount entry with a sats/BTC unit toggle (on-chain). `amountEntry` is
// --- what the user types in the chosen unit; `amount` stays the canonical
// --- sats value the rest of the flow reads and writes.
const amountUnit = ref<'sats' | 'btc'>('sats')
const amountEntry = ref<number>(0)
const amount = computed<number>({
get: () =>
amountUnit.value === 'btc'
? Math.round((amountEntry.value || 0) * 100_000_000)
: Math.floor(amountEntry.value || 0),
set: (sats: number) => {
amountEntry.value = amountUnit.value === 'btc' ? (sats || 0) / 100_000_000 : sats || 0
},
})
/** Switch entry unit, converting whatever is already typed. */
function setAmountUnit(unit: 'sats' | 'btc') {
if (unit === amountUnit.value) return
const sats = amount.value
amountUnit.value = unit
amount.value = sats
}
// Only the on-chain tab offers BTC entry — leaving it snaps back to sats so
// the lightning/ecash flows (and their sats-only hints) stay consistent.
watch(sendMethod, (m) => {
if (m !== 'onchain' && amountUnit.value !== 'sats') setAmountUnit('sats')
})
const amountLabel = computed(() =>
sendMethod.value === 'onchain' && amountUnit.value === 'btc' ? 'Amount (BTC)' : t('sendBitcoin.amountSats')
)
const unitConversionHint = computed(() => {
if (sendMethod.value !== 'onchain' || !amountEntry.value) return ''
return amountUnit.value === 'btc'
? `= ${amount.value.toLocaleString()} sats`
: `= ${(amount.value / 100_000_000).toFixed(8).replace(/0+$/, '').replace(/\.$/, '')} BTC`
})
const dest = ref('')
const processing = ref(false)
const error = ref('')
const resultTxid = ref('')
const resultHash = ref('')
const resultArk = ref('')
// Set on a completed send — flips the modal to the success pane.
const successInfo = ref<{
amount: number
methodLabel: string
hash?: string
txid?: string
note?: string
} | null>(null)
const copiedDetail = ref('')
function copyDetail(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
copiedDetail.value = text
setTimeout(() => {
if (copiedDetail.value === text) copiedDetail.value = ''
}, 1500)
}
const ecashToken = ref('')
// "Send all funds" — sweeps the whole on-chain balance (explicit on-chain tab only)
@@ -193,22 +348,78 @@ function toggleSendAll() {
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false })
// Invoice-first lightning UX: a pasted invoice that fixes its amount locks
// the amount field (auto-filled, "set by invoice"); zero-amount invoices
// leave it editable. Clearing/leaving lightning unlocks again.
const pastedInvoiceAmount = computed<number | null>(() => {
if (effectiveMethod.value !== 'lightning') return null
const d = dest.value.trim()
if (!d) return null
return parseBolt11AmountSats(d.toLowerCase().startsWith('lightning:') ? d.slice(10) : d)
})
watch(pastedInvoiceAmount, (fixed, prev) => {
if (fixed !== null) amount.value = fixed
// Swapping a fixed-amount invoice for a zero-amount one: don't silently
// keep the previous invoice's sats — make the user type the new amount.
else if (prev !== null) amount.value = 0
// --- On-chain network fee: presets map to LND confirmation targets; custom
// --- takes a block target or an explicit sat/vB rate (rate wins).
type OnchainFeePreset = 'fast' | 'standard' | 'slow' | 'custom'
const onchainFeePresets: { key: OnchainFeePreset; label: string; hint?: string; confTarget?: number }[] = [
{ key: 'fast', label: 'Fast', hint: 'Targets the next block (~10 minutes)', confTarget: 1 },
{ key: 'standard', label: 'Standard', hint: 'Confirms within ~6 blocks (about an hour)', confTarget: 6 },
{ key: 'slow', label: 'Slow', hint: 'Confirms within ~144 blocks (about a day)', confTarget: 144 },
{ key: 'custom', label: 'Custom' },
]
const feePreset = ref<OnchainFeePreset>('standard')
const customConfTarget = ref<number | null>(null)
const customSatPerVbyte = ref<number | null>(null)
// Resolved at review time so confirm + send use the same params.
const resolvedFeeParams = ref<{ target_conf?: number; sat_per_vbyte?: number }>({})
function onchainFeeParams(): { target_conf?: number; sat_per_vbyte?: number } | null {
if (feePreset.value !== 'custom') {
return { target_conf: onchainFeePresets.find(p => p.key === feePreset.value)?.confTarget ?? 6 }
}
const rate = customSatPerVbyte.value
const conf = customConfTarget.value
if (rate != null && rate !== 0) {
if (rate < 1 || rate > 5000) { error.value = 'Sats per vByte must be between 1 and 5000'; return null }
return { sat_per_vbyte: Math.floor(rate) }
}
if (conf != null && conf !== 0) {
if (conf < 1 || conf > 1008) { error.value = 'Target blocks must be between 1 and 1008'; return null }
return { target_conf: Math.floor(conf) }
}
error.value = 'Custom fee requires target blocks or sats per vByte'
return null
}
// Fee estimate for the confirm pane (best-effort — LND's own estimator).
const feeEstimate = ref<{ fee_sat: number; sat_per_vbyte: number } | null>(null)
const feeEstimateLoading = ref(false)
const feeEstimateLabel = computed(() => {
if (feeEstimate.value) {
return `~${feeEstimate.value.fee_sat.toLocaleString()} sats · ${feeEstimate.value.sat_per_vbyte} sat/vB`
}
if (resolvedFeeParams.value.sat_per_vbyte) {
return `${resolvedFeeParams.value.sat_per_vbyte} sat/vB (custom)`
}
if (feeEstimateLoading.value) return '…'
return isSweep.value ? 'deducted from swept amount' : 'estimated at broadcast'
})
async function loadFeeEstimate() {
feeEstimate.value = null
// Explicit sat/vB shows as-is; sweeps have no fixed amount to estimate on.
if (resolvedFeeParams.value.sat_per_vbyte || isSweep.value) return
const addr = dest.value.trim()
const amt = confirmAmount.value
if (!addr || amt < 546) return
feeEstimateLoading.value = true
try {
const res = await rpcClient.call<{ fee_sat: number; sat_per_vbyte: number }>({
method: 'lnd.estimatefee',
params: { addr, amount: amt, target_conf: resolvedFeeParams.value.target_conf ?? 6 },
timeout: 10000,
})
if (res.fee_sat > 0) feeEstimate.value = res
} catch {
/* estimate is a preview — the label falls back to prose */
} finally {
feeEstimateLoading.value = false
}
}
// Clipboard read needs a secure context (or the companion bridge); hide the
// button where it can't work — the textarea still accepts a manual paste.
const canReadClipboard = typeof navigator !== 'undefined' && !!navigator.clipboard?.readText
@@ -231,6 +442,25 @@ const effectiveMethod = computed(() => {
return 'lightning'
})
// Invoice-first lightning UX: a pasted invoice that fixes its amount locks
// the amount field (auto-filled, "set by invoice"); zero-amount invoices
// leave it editable. Clearing/leaving lightning unlocks again.
// MUST come after effectiveMethod: watch() evaluates its source getter at
// setup, and reading a const still in its temporal dead zone crashed the
// whole modal at mount ("Cannot access 'R' before initialization").
const pastedInvoiceAmount = computed<number | null>(() => {
if (effectiveMethod.value !== 'lightning') return null
const d = dest.value.trim()
if (!d) return null
return parseBolt11AmountSats(d.toLowerCase().startsWith('lightning:') ? d.slice(10) : d)
})
watch(pastedInvoiceAmount, (fixed, prev) => {
if (fixed !== null) amount.value = fixed
// Swapping a fixed-amount invoice for a zero-amount one: don't silently
// keep the previous invoice's sats — make the user type the new amount.
else if (prev !== null) amount.value = 0
})
// --- Second-step confirmation (parity with the scan flow): review shows the
// --- balance reduction before anything is sent or any token is minted.
@@ -309,20 +539,37 @@ function review() {
if (!isSweep.value && confirmAmount.value <= 0 && invoiceAmountSats.value === null) {
error.value = t('sendBitcoin.amountSats'); return
}
if (method === 'onchain') {
const fee = onchainFeeParams()
if (!fee) return
resolvedFeeParams.value = fee
void loadFeeEstimate()
} else {
resolvedFeeParams.value = {}
feeEstimate.value = null
}
void loadConfirmBalance()
confirming.value = true
}
function close() {
error.value = ''
resultTxid.value = ''
resultHash.value = ''
resultArk.value = ''
ecashToken.value = ''
confirming.value = false
successInfo.value = null
emit('close')
}
/** Reset the form for a fresh payment straight from the success screen. */
function sendAnother() {
successInfo.value = null
confirming.value = false
dest.value = ''
amount.value = 0
sendAll.value = false
error.value = ''
}
function copyText(text: string) {
navigator.clipboard.writeText(text).catch(() => {})
}
@@ -345,11 +592,9 @@ async function send() {
processing.value = true
error.value = ''
ecashToken.value = ''
resultTxid.value = ''
resultHash.value = ''
resultArk.value = ''
const method = effectiveMethod.value
const paidAmount = confirmAmount.value
try {
if (method === 'ark') {
if (!dest.value.trim()) { error.value = 'Enter an Ark address, invoice or lightning address'; return }
@@ -359,7 +604,7 @@ async function send() {
// Ark sends can wait on round participation.
timeout: 130000,
})
resultArk.value = `Sent ${amount.value.toLocaleString()} sats via Ark`
successInfo.value = { amount: paidAmount, methodLabel: 'Sent via Ark', note: 'The transfer settles with the next Ark round.' }
} else if (method === 'ecash') {
const res = await rpcClient.call<{ token: string }>({
method: 'wallet.ecash-send',
@@ -375,23 +620,38 @@ async function send() {
ecashToken.value = res.token
} else if (method === 'lightning') {
if (!dest.value.trim()) { error.value = t('web5.pasteInvoice'); return }
const res = await rpcClient.call<{ payment_hash: string }>({
method: 'lnd.payinvoice',
params: { payment_request: dest.value.trim() },
})
resultHash.value = res.payment_hash
// Waits out slow multi-hop routing and only reports failure when LND
// itself declares the payment failed — never on a timeout.
const res = await rpcClient.payLightningInvoice({ payment_request: dest.value.trim() })
if (res.status === 'failed') {
error.value = res.failure_reason || t('web5.sendFailed')
return
}
successInfo.value = {
amount: paidAmount,
methodLabel: res.status === 'pending' ? 'Payment in flight' : 'Paid over Lightning',
hash: res.payment_hash || undefined,
...(res.status === 'pending'
? { note: 'This payment is taking longer than usual to settle. It will appear in your transactions once it completes.' }
: {}),
}
} else {
if (!dest.value.trim()) { error.value = t('web5.enterBitcoinAddress'); return }
const res = await rpcClient.call<{ txid: string }>({
method: 'lnd.sendcoins',
params: isSweep.value
? { addr: dest.value.trim(), send_all: true }
: { addr: dest.value.trim(), amount: amount.value },
? { addr: dest.value.trim(), send_all: true, ...resolvedFeeParams.value }
: { addr: dest.value.trim(), amount: amount.value, ...resolvedFeeParams.value },
})
resultTxid.value = res.txid
successInfo.value = {
amount: paidAmount,
methodLabel: isSweep.value ? 'Swept on-chain' : 'Sent on-chain',
txid: res.txid,
note: 'On-chain payments confirm over the next blocks.',
}
}
emit('sent')
// Back to the form pane so the success/token panes are visible.
// Success pane (or the token pane for ecash mints) takes over the modal.
confirming.value = false
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : t('web5.sendFailed')
@@ -400,3 +660,59 @@ async function send() {
}
}
</script>
<style scoped>
/* Success burst — pop-in check inside radiating rings, wallet palette
(emerald for the settled payment, one Archipelago-orange ring). */
.send-success-burst {
position: relative;
width: 7rem;
height: 7rem;
}
.burst-core {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: rgba(16, 185, 129, 0.12);
box-shadow: 0 0 48px rgba(16, 185, 129, 0.3);
animation: burst-pop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.4) both;
}
.burst-check {
stroke-dasharray: 32;
stroke-dashoffset: 32;
animation: burst-draw 0.45s ease-out 0.25s forwards;
}
.burst-ring {
position: absolute;
inset: 0;
border-radius: 9999px;
border: 2px solid rgba(16, 185, 129, 0.45);
animation: burst-ripple 1.8s ease-out infinite;
}
.burst-ring-2 {
animation-delay: 0.45s;
}
.burst-ring-3 {
animation-delay: 0.9s;
border-color: rgba(249, 115, 22, 0.35);
}
@keyframes burst-pop {
from { transform: scale(0.3); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes burst-draw {
to { stroke-dashoffset: 0; }
}
@keyframes burst-ripple {
0% { transform: scale(0.7); opacity: 0.9; }
100% { transform: scale(2); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.burst-core, .burst-check, .burst-ring { animation: none; }
.burst-check { stroke-dashoffset: 0; }
.burst-ring { display: none; }
}
</style>
+9 -8
View File
@@ -701,16 +701,17 @@ async function confirmSend() {
error.value = ''
try {
if (action.value === 'pay-invoice') {
const params: Record<string, unknown> = { payment_request: dest.value }
const params: { payment_request: string; amount_sats?: number } = { payment_request: dest.value }
if (!amountLocked.value && effectiveAmount.value > 0) params.amount_sats = effectiveAmount.value
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
method: 'lnd.payinvoice',
params,
timeout: 60000,
})
// Waits out slow multi-hop routing and only reports failure when LND
// itself declares the payment failed — never on a timeout.
const res = await rpcClient.payLightningInvoice(params)
if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed')
successAmount.value = res.amount_sats || effectiveAmount.value
successVerb.value = 'PAID'
successDetail.value = 'Lightning invoice paid'
successVerb.value = res.status === 'pending' ? 'SENDING' : 'PAID'
successDetail.value = res.status === 'pending'
? 'Payment in flight — it will appear in your transactions once it settles'
: 'Lightning invoice paid'
successRef.value = res.payment_hash
} else if (action.value === 'send-onchain') {
const res = await rpcClient.call<{ txid: string }>({
@@ -1,7 +1,7 @@
<template>
<BaseModal
:show="show"
:title="step === 1 ? 'Mesh Radio Detected' : step === 2 ? 'Apply Archipelago Settings' : 'Flash Firmware'"
:title="step === 1 ? 'Mesh Radio Detected' : step === 2 ? 'Set Recommended' : 'Flash Firmware'"
max-width="max-w-lg"
content-class="max-h-[90vh] overflow-y-auto"
@close="dismiss"
@@ -35,9 +35,17 @@
<!-- What's currently flashed / configured on it -->
<div class="mt-4 text-left rounded-xl bg-white/[0.05] border border-white/10 p-3">
<div v-if="probing" class="flex items-center gap-2 text-white/60 text-sm py-1">
<span class="inline-block w-3.5 h-3.5 rounded-full border-2 border-orange-300/70 border-t-transparent animate-spin"></span>
Reading what's on the radio
<div v-if="probing" class="py-1">
<div class="flex items-center justify-between text-white/60 text-sm mb-1.5">
<span>{{ probeStage }}</span>
<span class="text-white/40 text-xs tabular-nums">{{ Math.round(probeProgress) }}%</span>
</div>
<div class="h-1.5 rounded-full bg-white/10 overflow-hidden">
<div
class="h-full rounded-full bg-orange-400/80 transition-[width] duration-500 ease-linear"
:style="{ width: probeProgress + '%' }"
></div>
</div>
</div>
<template v-else-if="probe">
<div class="flex items-center gap-2">
@@ -94,7 +102,7 @@
:disabled="!!connecting"
@click="step = 2"
>
Set Up with Archipelago Settings
Set Recommended
</button>
</div>
<p class="text-white/40 text-[11px] mt-3">
@@ -209,8 +217,8 @@
<!-- Step 2: our latest parameters, shown before anything is written -->
<div v-else>
<p class="text-white/60 text-xs mb-3">
These are the latest Archipelago settings nothing is written to the
radio until you confirm.
These are the recommended Archipelago settings nothing is written to
the radio until you confirm.
</p>
<!-- Summary of what will be applied -->
@@ -308,6 +316,30 @@ const probing = ref(false)
const probe = ref<MeshDeviceProbe | null>(null)
const probeError = ref('')
// Time-driven probe progress: the probe RPC is a single opaque call that can
// take ~5-30s (boot settle + up to three firmware handshakes), so the bar
// advances on a clock toward 92% and snaps to 100% when the result lands.
const probeProgress = ref(0)
const probeStage = ref('Waiting for the radio to boot…')
let probeTicker: ReturnType<typeof setInterval> | null = null
function startProbeProgress() {
stopProbeProgress()
probeProgress.value = 0
probeStage.value = 'Waiting for the radio to boot…'
const startedAt = Date.now()
probeTicker = setInterval(() => {
const elapsed = (Date.now() - startedAt) / 1000
// ~92% at 30s, decelerating never looks stuck, never lies "done".
probeProgress.value = Math.min(92, 100 * (1 - Math.exp(-elapsed / 11)))
if (elapsed >= 4) probeStage.value = 'Detecting firmware…'
if (elapsed >= 18) probeStage.value = 'Still checking (radios can be slow to answer)…'
}, 400)
}
function stopProbeProgress(done = false) {
if (probeTicker) { clearInterval(probeTicker); probeTicker = null }
if (done) probeProgress.value = 100
}
const devicePath = computed(() => mesh.undismissedDetectedDevices[0] ?? '')
const show = computed(() => !!devicePath.value)
const imageFailed = ref(false)
@@ -380,6 +412,7 @@ watch([show, devicePath], async ([visible]) => {
probe.value = null
probeError.value = ''
probing.value = true
startProbeProgress()
const path = devicePath.value
try {
const res = await mesh.probeDevice(path)
@@ -389,6 +422,7 @@ watch([show, devicePath], async ([visible]) => {
probeError.value = e instanceof Error ? e.message : String(e)
}
} finally {
stopProbeProgress(true)
if (devicePath.value === path) probing.value = false
}
}, { immediate: false })