archy/neode-ui/src/components/WalletScanModal.vue
archipelago 9cd507269c fix(lightning): never report a slow in-flight payment as failed
Slow multi-hop payments (>15s routing) surfaced as "Payment failed"
while LND settled them in the background: the shared LND REST client's
15s total timeout aborted the synchronous /v1/channels/transactions
wait, and every UI path treated that abort as a definitive failure. The
payment then succeeded anyway and only appeared in history on the next
background poll.

Backend: lnd.payinvoice now decodes the invoice up front for its payment
hash, pays on a dedicated 120s client, and answers status:"pending" with
the hash (never an error) when the wait elapses after the payment was
handed to LND — only a pre-connect failure is still a hard error. New
lnd.paymentstatus RPC reports succeeded/failed/in_flight (with humanized
failure reasons) from /v1/payments.

Frontend: new rpcClient.payLightningInvoice() pays then polls
lnd.paymentstatus to a real terminal state (3s interval, up to 2 min);
all five call sites (send modal, scan modal, web5 unified send, peer-file
purchase, app-launcher payments) migrated. Failure is only shown when LND
itself declares FAILED; a still-settling payment shows an in-flight state
and success fires the transaction refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 05:33:02 -04:00

854 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4"
@click.self="close"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-md"></div>
<div
ref="modalRef"
class="glass-card p-6 w-full max-w-md relative z-10 overflow-hidden"
role="dialog"
aria-modal="true"
@click.stop
>
<!-- Header -->
<div class="flex items-center justify-between gap-4 mb-4">
<div class="flex items-center gap-2 min-w-0">
<button
v-if="pane !== 'scan'"
@click="goBack"
class="p-2 -ml-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors shrink-0"
aria-label="Back"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<h3 class="text-xl font-semibold text-white truncate">{{ paneTitle }}</h3>
</div>
<button
@click="close"
class="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors shrink-0"
aria-label="Close"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<Transition :name="direction === 'forward' ? 'pane-forward' : 'pane-back'" mode="out-in">
<!-- ============ SCAN PANE ============ -->
<div v-if="pane === 'scan'" key="scan">
<!-- Chooser interstitial — every open: the user picks live camera
or a photo upload; neither starts until chosen. -->
<div v-if="scanChoice === 'unset'" class="w-full rounded-xl bg-black/30 border border-white/10 mb-4 p-6 flex flex-col items-center gap-3">
<svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
</svg>
<p class="text-sm text-white/60 text-center">How do you want to read the QR?</p>
<!-- hasNativeQr: on the companion (plain http, no getUserMedia)
the native bridge still provides a live camera -->
<button v-if="!liveCameraUnavailable || hasNativeQr" @click="chooseCamera" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
Scan with camera
</button>
<button @click="photoInput?.click()" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
Upload / take a photo of the QR
</button>
</div>
<div v-else class="relative w-full aspect-square rounded-xl overflow-hidden bg-black/40 border border-white/10 mb-4">
<!-- opacity (not v-if/v-show): the scanner needs the element, and a
source-less <video> flashes a native play glyph in Android WebViews -->
<video ref="videoElement" class="w-full h-full object-cover transition-opacity duration-200" :class="isScanning ? 'opacity-100' : 'opacity-0'" autoplay muted playsinline></video>
<!-- Viewfinder -->
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="scan-viewfinder"></div>
</div>
<!-- While auto-start is settling, show only a quiet spinner — the
Start/Take-photo buttons are the FALLBACK, not a splash screen -->
<div v-if="!isScanning && autoStarting" class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/50">
<svg class="w-8 h-8 text-white/40 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v3a5 5 0 00-5 5H4z" />
</svg>
</div>
<div v-else-if="!isScanning" class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-black/50">
<svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<button v-if="!liveCameraUnavailable" @click="startScanning" class="glass-button px-4 py-2 rounded-lg text-sm font-medium">
{{ cameraError ? 'Retry camera' : 'Start camera' }}
</button>
<!-- Insecure-context fallback: live getUserMedia preview needs
HTTPS, but the native camera via a file input does not —
snap a photo of the QR and decode it locally. -->
<button @click="photoInput?.click()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium">
Take photo of QR
</button>
</div>
</div>
<!-- Single always-mounted picker input, shared by the chooser and
the in-camera fallback button -->
<input
ref="photoInput"
type="file"
accept="image/*"
capture="environment"
class="hidden"
@change="onPhotoPicked"
/>
<div class="mb-4 p-3 bg-white/5 rounded-lg min-h-[3rem] flex items-center justify-center">
<p class="text-sm text-center" :class="scanStatusIsError ? 'text-red-400' : 'text-white/60'">
{{ scanStatus || 'Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code' }}
</p>
</div>
<!-- Paste fallback (also the path on camera-less nodes) -->
<div class="flex gap-2">
<input
v-model="pasteInput"
type="text"
placeholder="…or paste an invoice / address / token"
class="flex-1 input-glass font-mono text-xs"
@keydown.enter="submitPaste"
/>
<button @click="submitPaste" :disabled="!pasteInput.trim()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-40">
Use
</button>
</div>
</div>
<!-- ============ AMOUNT / CONFIRM PANE ============ -->
<div v-else-if="pane === 'amount'" key="amount">
<!-- Rail + destination summary -->
<div class="flex items-center gap-3 p-3 bg-white/5 rounded-lg mb-4">
<span class="w-9 h-9 rounded-lg bg-white/10 flex items-center justify-center shrink-0" :class="railColor">
<span v-if="rail === 'onchain'" class="text-lg font-bold">&#x20bf;</span>
<svg v-else-if="rail === 'lightning'" class="w-5 h-5" 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>
<!-- SVG, not 🥜: kiosk images ship no color-emoji font -->
<svg v-else-if="rail === 'cashu'" class="w-5 h-5" role="img" aria-label="Cashu" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
<svg v-else class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a4 4 0 00-3-3.87M9 20H4v-2a4 4 0 013-3.87m6-.13a4 4 0 10-4-4 4 4 0 004 4zm6 0a4 4 0 10-3-6.65" />
</svg>
</span>
<div class="min-w-0">
<p class="text-sm font-medium text-white">{{ railLabel }}</p>
<p class="text-[11px] text-white/40 font-mono truncate">{{ destDisplay }}</p>
</div>
</div>
<!-- Federation join is a confirm, not an amount entry -->
<template v-if="action === 'fedimint-join'">
<p class="text-sm text-white/70 mb-4">
This is a Fedimint federation invite. Join it to hold and send ecash backed by this federation.
</p>
</template>
<template v-else>
<!-- Amount -->
<div class="mb-3">
<div class="flex items-center justify-between mb-1">
<label class="text-white/60 text-sm">Amount (sats)</label>
<span v-if="amountLocked" class="text-[11px] px-2 py-0.5 rounded-full bg-white/10 text-white/50">set by invoice</span>
</div>
<input
v-model.number="amount"
type="number"
min="1"
inputmode="numeric"
placeholder="0"
:disabled="amountLocked || sendMax"
class="w-full input-glass text-2xl font-semibold text-center disabled:opacity-70"
/>
</div>
<!-- Presets + Max -->
<div v-if="!amountLocked" class="grid grid-cols-5 gap-2 mb-4">
<button
v-for="preset in PRESETS"
:key="preset"
@click="applyPreset(preset)"
:disabled="balance !== null && preset > balance"
class="px-1 py-1.5 rounded-lg text-xs font-medium transition-colors disabled:opacity-30"
:class="!sendMax && amount === preset ? 'bg-white/20 text-white' : 'bg-white/5 text-white/60 hover:bg-white/10 hover:text-white/90'"
>{{ formatPreset(preset) }}</button>
<button
@click="toggleMax"
class="px-1 py-1.5 rounded-lg text-xs font-semibold transition-colors"
:class="sendMax ? 'bg-orange-500/25 text-orange-300 border border-orange-500/40' : 'bg-white/5 text-white/60 hover:bg-white/10 hover:text-white/90'"
>Max</button>
</div>
<!-- Live balance impact -->
<div class="rounded-lg border border-white/10 divide-y divide-white/5 mb-4">
<div class="flex items-center justify-between px-3 py-2">
<span class="text-xs text-white/50">{{ railLabel }} balance</span>
<span class="text-sm font-medium" :class="railColor">
{{ balance === null ? '…' : balance.toLocaleString() + ' sats' }}
</span>
</div>
<div class="flex items-center justify-between px-3 py-2">
<span class="text-xs text-white/50">This send</span>
<span class="text-sm font-medium text-white/80">{{ effectiveAmount.toLocaleString() }} sats</span>
</div>
<div class="flex items-center justify-between px-3 py-2">
<span class="text-xs text-white/50">Balance after</span>
<span class="text-sm font-semibold" :class="insufficient ? 'text-red-400' : 'text-white'">
{{ balance === null ? '…' : balanceAfter.toLocaleString() + ' sats' }}
</span>
</div>
</div>
<p v-if="insufficient" class="text-xs text-red-400 mb-3">Not enough {{ railLabel }} balance for this amount.</p>
<p v-else-if="sendMax && rail === 'onchain'" class="text-xs text-white/50 mb-3">Sweeps the entire on-chain balance minus network fees.</p>
</template>
<div v-if="error" class="mb-3 alert-error text-sm">{{ error }}</div>
<div class="flex gap-3">
<button @click="goBack" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Back</button>
<button
@click="confirmSend"
:disabled="processing || (action !== 'fedimint-join' && (insufficient || (effectiveAmount <= 0 && !sendMax)))"
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
>
{{ processing ? 'Sending…' : confirmLabel }}
</button>
</div>
</div>
<!-- ============ SUCCESS PANE ============ -->
<div v-else key="success" class="text-center py-2">
<div class="inline-flex items-center justify-center w-24 h-24 rounded-full mx-auto mb-5 success-ring">
<svg class="w-14 h-14 text-green-400" 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 v-if="successAmount > 0" class="text-5xl font-black text-green-400 mb-1">
{{ successAmount.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-2">{{ successVerb }}</div>
<p class="text-sm text-white/50 mb-1">{{ successDetail }}</p>
<p v-if="successRef" class="text-[11px] text-white/30 font-mono break-all px-4 mb-4">{{ successRef }}</p>
<div v-if="balance !== null" class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/5 text-xs text-white/60 mb-5">
<span :class="railColor">{{ railLabel }}</span>
<span>balance now {{ balance.toLocaleString() }} sats</span>
</div>
<button
@click="close"
class="w-full py-3 rounded-xl font-semibold text-base bg-white/10 hover:bg-white/20 text-white transition-colors"
>
Done
</button>
</div>
</Transition>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
import QrScanner from 'qr-scanner'
import { rpcClient } from '@/api/rpc-client'
import { useAnimatedQRDecoder } from '@/composables/useAnimatedQRDecoder'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
type Rail = 'onchain' | 'lightning' | 'cashu' | 'fedimint'
type Action = 'pay-invoice' | 'send-onchain' | 'redeem-token' | 'fedimint-join'
type Pane = 'scan' | 'amount' | 'success'
// JS bridge the Android companion injects: when present, live scanning is
// delegated to a native camera modal (styled like this one) — the WebView's
// getUserMedia preview lags, and over plain http it doesn't exist at all.
// Decodes come back through the window.__archyQr* callbacks; status lines
// (animated-QR progress, errors) mirror out to the native modal's strip.
interface ArchipelagoQrBridge {
open(): void
setStatus(message: string, isError: boolean): void
close(): void
}
interface NativeWindow extends Window {
ArchipelagoQr?: ArchipelagoQrBridge
__archyQrResult?: (text: string) => void
__archyQrCancelled?: () => void
}
const nativeWin = window as NativeWindow
const PRESETS = [21, 2100, 21000, 100000]
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; sent: [] }>()
const modalRef = ref<HTMLElement | null>(null)
useModalKeyboard(modalRef, computed(() => props.show), close)
useBodyScrollLock(computed(() => props.show))
// --- Pane state ---
const pane = ref<Pane>('scan')
const direction = ref<'forward' | 'back'>('forward')
const paneTitle = computed(() => {
if (pane.value === 'scan') return 'Scan to send'
if (pane.value === 'success') return 'Success'
if (action.value === 'fedimint-join') return 'Join federation'
if (action.value === 'redeem-token') return 'Redeem'
return 'Send'
})
function goTo(p: Pane, dir: 'forward' | 'back' = 'forward') {
direction.value = dir
pane.value = p
}
function goBack() {
if (pane.value === 'amount') {
error.value = ''
goTo('scan', 'back')
// Only relight the camera when that's what the user chose; otherwise
// they land back on the scan/upload chooser.
nextTick(() => { if (scanChoice.value === 'camera' && !liveCameraUnavailable.value) startScanning() })
} else if (pane.value === 'success') {
close()
}
}
// --- Scanner ---
const videoElement = ref<HTMLVideoElement | null>(null)
const isScanning = ref(false)
// True while a camera-start attempt is in flight (incl. the automatic one on
// open) — the fallback buttons stay hidden until it resolves, so they don't
// flash for a second on every open.
const autoStarting = ref(false)
// Camera-vs-photo chooser, shown on EVERY open (user request 2026-07-23):
// nothing starts until the user picks, and Back from a later pane returns to
// the live camera only if that's what they chose.
const scanChoice = ref<'unset' | 'camera'>('unset')
function chooseCamera() {
if (startNativeScan()) return
scanChoice.value = 'camera'
void nextTick(() => startScanning())
}
// --- Native scanner (companion app) ---
const hasNativeQr = !!nativeWin.ArchipelagoQr
const nativeScanActive = ref(false)
function startNativeScan(): boolean {
const bridge = nativeWin.ArchipelagoQr
if (!bridge) return false
nativeWin.__archyQrResult = (text: string) => handleScanned(text)
nativeWin.__archyQrCancelled = () => { nativeScanActive.value = false }
nativeScanActive.value = true
bridge.open()
return true
}
const scanStatus = ref('')
const scanStatusIsError = ref(false)
const cameraError = ref(false)
const pasteInput = ref('')
const qrScanner = ref<QrScanner | null>(null)
const animatedDecoder = useAnimatedQRDecoder()
async function startScanning() {
cameraError.value = false
scanStatusIsError.value = false
autoStarting.value = true
try {
if (!videoElement.value) return
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error(
location.protocol !== 'https:' && !['localhost', '127.0.0.1'].includes(location.hostname)
? 'Camera needs HTTPS — paste the code below instead'
: 'No camera available — paste the code below instead'
)
}
qrScanner.value = new QrScanner(
videoElement.value,
(result) => handleScanned(result.data),
{
returnDetailedScanResult: true,
highlightScanRegion: false,
preferredCamera: 'environment',
// 4/s decodes plenty fast for a hand-held QR while keeping the
// preview smooth on phone WebViews (10/s visibly lagged the video).
maxScansPerSecond: 4,
}
)
await qrScanner.value.start()
isScanning.value = true
scanStatus.value = ''
} catch (err) {
cameraError.value = true
scanStatusIsError.value = true
scanStatus.value = err instanceof Error ? err.message : 'Unable to access camera'
} finally {
autoStarting.value = false
}
}
function stopScanning() {
qrScanner.value?.stop()
qrScanner.value?.destroy()
qrScanner.value = null
isScanning.value = false
if (nativeScanActive.value) {
nativeScanActive.value = false
nativeWin.ArchipelagoQr?.close()
}
}
// Mirror status lines onto the native modal while it's up — it covers the
// page, so this strip is the only feedback the user can see.
watch([scanStatus, scanStatusIsError], () => {
if (nativeScanActive.value) {
nativeWin.ArchipelagoQr?.setStatus(scanStatus.value, scanStatusIsError.value)
}
})
function submitPaste() {
const text = pasteInput.value.trim()
if (!text) return
handleScanned(text)
}
// --- Photo-capture fallback (works without a secure context) ---
// getUserMedia is unreachable over plain http (navigator.mediaDevices is
// undefined), but <input capture> opens the native camera in any browser,
// PWA or WebView; the shot is decoded locally by qr-scanner's scanImage.
const photoInput = ref<HTMLInputElement | null>(null)
const liveCameraUnavailable = computed(() => !navigator.mediaDevices?.getUserMedia)
async function onPhotoPicked(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
scanStatusIsError.value = false
scanStatus.value = 'Reading photo…'
try {
handleScanned(await decodePhotoRobust(file))
} catch {
scanStatusIsError.value = true
scanStatus.value = 'No QR code found in that photo — try again, closer and well-lit'
} finally {
input.value = ''
}
}
/** Decode a QR photo with every engine we have. On the companion app the
* photo path IS the scan path (plain-http LAN = no secure context = no
* live camera), and Lightning invoices make DENSE codes that the wasm
* engine's single pass often misses (reported 2026-07-22: "camera not
* picking up the invoice"). Android WebView's native BarcodeDetector is
* far stronger on dense codes, so try it first; fall back to qr-scanner. */
async function decodePhotoRobust(file: File): Promise<string> {
try {
const Detector = (window as unknown as { BarcodeDetector?: new (opts: { formats: string[] }) => { detect(src: ImageBitmap): Promise<Array<{ rawValue: string }>> } }).BarcodeDetector
if (Detector) {
const bmp = await createImageBitmap(file)
const codes = await new Detector({ formats: ['qr_code'] }).detect(bmp)
const hit = codes.find(c => c.rawValue)
if (hit) return hit.rawValue
}
} catch {
// Native detector unavailable/failed — wasm engine below.
}
const result = await QrScanner.scanImage(file, { returnDetailedScanResult: true })
return result.data
}
// --- Detection (ported from k484's scanner) ---
const rail = ref<Rail>('lightning')
const action = ref<Action>('pay-invoice')
const dest = ref('')
const amount = ref<number>(0)
const amountLocked = ref(false)
const sendMax = ref(false)
const railLabel = computed(() => ({
onchain: 'On-chain',
lightning: 'Lightning',
cashu: 'Cashu',
fedimint: 'Fedimint',
}[rail.value]))
const railColor = computed(() => ({
onchain: 'text-orange-500',
lightning: 'text-yellow-400',
cashu: 'text-purple-400',
fedimint: 'text-blue-400',
}[rail.value]))
const destDisplay = computed(() =>
dest.value.length > 46 ? `${dest.value.slice(0, 26)}${dest.value.slice(-14)}` : dest.value
)
const confirmLabel = computed(() => {
if (action.value === 'fedimint-join') return 'Join'
if (action.value === 'redeem-token') return 'Redeem'
return 'Send'
})
/** Amount encoded in a BOLT11 invoice's human-readable part, in sats (null = zero-amount). */
function parseBolt11AmountSats(invoice: string): number | null {
const m = /^ln(?:bcrt|bc|tb)(\d+)?([munp])?1/.exec(invoice.toLowerCase())
if (!m || !m[1]) return null
const value = Number(m[1])
const mult = { m: 1e-3, u: 1e-6, n: 1e-9, p: 1e-12 }[m[2] as 'm' | 'u' | 'n' | 'p'] ?? 1
return Math.round(value * mult * 1e8)
}
function isLightningInvoice(text: string): boolean {
const t = text.toLowerCase()
return t.startsWith('lnbc') || t.startsWith('lntb') || t.startsWith('lnbcrt')
}
function isOnchainAddress(text: string): boolean {
return /^(bc1|tb1|bcrt1)[a-z0-9]{20,90}$/i.test(text) || /^[13][a-km-zA-HJ-NP-Z1-9]{25,40}$/.test(text)
}
function isCashuToken(text: string): boolean {
return text.startsWith('cashuA') || text.startsWith('cashuB')
}
function isFedimintInvite(text: string): boolean {
return text.startsWith('fed11')
}
function isFedimintToken(text: string): boolean {
return /^AwE/.test(text)
}
function isAnimatedFrame(text: string): boolean {
return text.startsWith('B$') || text.startsWith('AAAH') || text.startsWith('ZAAE') || text.startsWith('AAA')
}
function handleScanned(raw: string) {
let text = raw.trim()
scanStatusIsError.value = false
// Animated multi-frame QR (large Fedimint tokens): keep collecting frames
if (isAnimatedFrame(text)) {
animatedDecoder.addFrame(text)
if (animatedDecoder.isComplete.value && animatedDecoder.decodedData.value) {
const token = animatedDecoder.decodedData.value
animatedDecoder.reset()
acceptDetected(token)
} else {
scanStatus.value = `Animated code… ${animatedDecoder.progressText()}`
}
return
}
acceptDetected(text)
}
function acceptDetected(raw: string) {
let text = raw.trim()
let bip21AmountSats: number | null = null
// URI schemes
if (text.toLowerCase().startsWith('lightning:')) text = text.slice(10)
if (text.toLowerCase().startsWith('bitcoin:')) {
try {
const uri = new URL(text)
const lnParam = uri.searchParams.get('lightning')
const amt = uri.searchParams.get('amount')
if (amt && !Number.isNaN(Number(amt))) bip21AmountSats = Math.round(Number(amt) * 1e8)
// Prefer the unified lightning param when present, else the address
text = lnParam || text.slice(8).split('?')[0] || ''
} catch {
text = text.slice(8).split('?')[0] || ''
}
}
if (isLightningInvoice(text)) {
rail.value = 'lightning'
action.value = 'pay-invoice'
dest.value = text
const invoiceAmount = parseBolt11AmountSats(text)
// Zero-amount invoice inside a unified BIP21 URI: prefill from amount=
amount.value = invoiceAmount ?? bip21AmountSats ?? 0
amountLocked.value = invoiceAmount !== null
} else if (isCashuToken(text)) {
rail.value = 'cashu'
action.value = 'redeem-token'
dest.value = text
amount.value = 0
amountLocked.value = true
} else if (isFedimintInvite(text)) {
rail.value = 'fedimint'
action.value = 'fedimint-join'
dest.value = text
amount.value = 0
amountLocked.value = true
} else if (isFedimintToken(text)) {
rail.value = 'fedimint'
action.value = 'redeem-token'
dest.value = text
amount.value = 0
amountLocked.value = true
} else if (isOnchainAddress(text)) {
rail.value = 'onchain'
action.value = 'send-onchain'
dest.value = text
amount.value = bip21AmountSats ?? 0
amountLocked.value = false
} else if (text.toLowerCase().startsWith('lno1')) {
// BOLT12 offer — LND (this node's Lightning backend) can't pay offers yet
scanStatusIsError.value = true
scanStatus.value = 'BOLT12 offers aren\'t supported yet — ask the recipient for a BOLT11 invoice'
return
} else if (text.toLowerCase().startsWith('lnurl1') || /^[\w.+-]+@[\w-]+(\.[\w-]+)+$/.test(text)) {
// LNURL-pay / lightning address — needs an HTTP callback flow we don't do yet
scanStatusIsError.value = true
scanStatus.value = 'LNURL and lightning addresses aren\'t supported yet — ask for a BOLT11 invoice'
return
} else {
scanStatusIsError.value = true
scanStatus.value = 'Not a recognised invoice, address or token'
return
}
sendMax.value = false
error.value = ''
stopScanning()
loadBalance()
// Tokens carry their own value — redeem straight away, success screen follows
if (action.value === 'redeem-token') {
goTo('amount')
confirmSend()
} else {
goTo('amount')
}
}
// --- Balance for the active rail ---
const balance = ref<number | null>(null)
async function loadBalance() {
balance.value = null
try {
if (rail.value === 'cashu') {
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance' })
balance.value = res.balance_sats ?? 0
} else if (rail.value === 'fedimint') {
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance' })
balance.value = res.balance_sats ?? 0
} else {
const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo' })
balance.value = rail.value === 'onchain' ? (res.balance_sats ?? 0) : (res.channel_balance_sats ?? 0)
}
} catch {
balance.value = null
}
}
const effectiveAmount = computed(() => {
if (sendMax.value) return balance.value ?? 0
return amount.value > 0 ? Math.floor(amount.value) : 0
})
const balanceAfter = computed(() => (balance.value ?? 0) - effectiveAmount.value)
const insufficient = computed(() =>
action.value !== 'fedimint-join' && action.value !== 'redeem-token' &&
balance.value !== null && effectiveAmount.value > balance.value
)
function applyPreset(preset: number) {
sendMax.value = false
amount.value = preset
}
function toggleMax() {
sendMax.value = !sendMax.value
if (sendMax.value && balance.value !== null && rail.value !== 'onchain') {
amount.value = balance.value
}
}
function formatPreset(preset: number): string {
return preset >= 1000 ? `${(preset / 1000).toLocaleString()}k` : String(preset)
}
// --- Send / redeem / join ---
const processing = ref(false)
const error = ref('')
const successAmount = ref(0)
const successVerb = ref('SENT')
const successDetail = ref('')
const successRef = ref('')
async function confirmSend() {
if (processing.value) return
processing.value = true
error.value = ''
try {
if (action.value === 'pay-invoice') {
const params: { payment_request: string; amount_sats?: number } = { payment_request: dest.value }
if (!amountLocked.value && effectiveAmount.value > 0) params.amount_sats = effectiveAmount.value
// 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 = 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 }>({
method: 'lnd.sendcoins',
params: sendMax.value
? { addr: dest.value, send_all: true }
: { addr: dest.value, amount: effectiveAmount.value },
timeout: 60000,
})
successAmount.value = sendMax.value ? (balance.value ?? 0) : effectiveAmount.value
successVerb.value = 'SENT'
successDetail.value = 'On-chain transaction broadcast'
successRef.value = res.txid
} else if (action.value === 'redeem-token') {
const res = await rpcClient.call<{ received_sats: number; kind: string }>({
method: 'wallet.ecash-receive',
params: { token: dest.value },
timeout: 60000,
})
rail.value = res.kind === 'fedimint' ? 'fedimint' : 'cashu'
successAmount.value = res.received_sats || 0
successVerb.value = 'RECEIVED'
successDetail.value = `${res.kind === 'fedimint' ? 'Fedimint' : 'Cashu'} token redeemed`
successRef.value = ''
} else {
await rpcClient.call({
method: 'wallet.fedimint-join',
params: { invite_code: dest.value },
timeout: 60000,
})
successAmount.value = 0
successVerb.value = 'JOINED'
successDetail.value = 'Federation joined — you can now hold its ecash'
successRef.value = ''
}
await loadBalance()
goTo('success')
emit('sent')
} catch (err) {
error.value = err instanceof Error ? err.message : 'Send failed'
// Token redeems jump straight from scan; surface the failure on the amount pane
if (pane.value !== 'amount') goTo('amount')
} finally {
processing.value = false
}
}
// --- Lifecycle ---
function resetAll() {
stopScanning()
animatedDecoder.reset()
pane.value = 'scan'
direction.value = 'forward'
scanStatus.value = ''
scanStatusIsError.value = false
cameraError.value = false
pasteInput.value = ''
dest.value = ''
amount.value = 0
amountLocked.value = false
sendMax.value = false
balance.value = null
processing.value = false
error.value = ''
}
function close() {
resetAll()
emit('close')
}
watch(() => props.show, (open) => {
if (open) {
resetAll()
// No auto-start: the chooser interstitial owns the first move every time.
scanChoice.value = 'unset'
} else {
stopScanning()
}
})
onUnmounted(stopScanning)
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .glass-card,
.modal-leave-active .glass-card {
transition: transform 0.2s ease;
}
.modal-enter-from .glass-card,
.modal-leave-to .glass-card {
transform: scale(0.95);
}
/* Horizontal pane slide: forward = new pane arrives from the right */
.pane-forward-enter-active,
.pane-forward-leave-active,
.pane-back-enter-active,
.pane-back-leave-active {
transition: transform 0.22s ease, opacity 0.22s ease;
}
.pane-forward-enter-from {
transform: translateX(40px);
opacity: 0;
}
.pane-forward-leave-to {
transform: translateX(-40px);
opacity: 0;
}
.pane-back-enter-from {
transform: translateX(-40px);
opacity: 0;
}
.pane-back-leave-to {
transform: translateX(40px);
opacity: 0;
}
.scan-viewfinder {
width: 62%;
height: 62%;
border-radius: 1rem;
border: 2px solid rgba(249, 115, 22, 0.85);
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.35);
}
.success-ring {
background: rgba(16, 185, 129, 0.12);
box-shadow: 0 0 40px rgba(16, 185, 129, 0.25);
}
</style>