From 6502f13e2991d8407c55007ed69da10daf744967 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 23 Jul 2026 15:30:42 -0400 Subject: [PATCH] feat(wallet): paste-send confirmation step + every-time scan/upload chooser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pasting an invoice/address now gets the same second-step confirmation the scan flow has: method, destination, live balance -> amount -> balance after (invoice-fixed amounts win over the typed one), for every rail including token minting. Nothing pays until Confirm & Send. - The scan modal opens on a chooser every time: scan with camera or upload/take a photo of the QR — the camera no longer auto-starts, and Back only relights it if camera was the user's choice. Co-Authored-By: Claude Fable 5 --- neode-ui/src/components/SendBitcoinModal.vue | 142 ++++++++++++++++++- neode-ui/src/components/WalletScanModal.vue | 53 +++++-- 2 files changed, 181 insertions(+), 14 deletions(-) diff --git a/neode-ui/src/components/SendBitcoinModal.vue b/neode-ui/src/components/SendBitcoinModal.vue index 2f8aa1cc..55a4364e 100644 --- a/neode-ui/src/components/SendBitcoinModal.vue +++ b/neode-ui/src/components/SendBitcoinModal.vue @@ -1,5 +1,54 @@ @@ -136,12 +186,95 @@ const effectiveMethod = computed(() => { return 'lightning' }) +// --- Second-step confirmation (parity with the scan flow): review shows the +// --- balance reduction before anything is sent or any token is minted. + +const confirming = ref(false) +const confirmBalance = ref(null) +const invoiceAmountSats = ref(null) + +const methodLabel = computed(() => ({ + auto: 'Auto', lightning: 'Lightning', onchain: 'On-chain', + ecash: 'Cashu', fedimint: 'Fedimint', ark: 'Ark', +}[effectiveMethod.value])) + +const methodColor = computed(() => ({ + auto: 'text-white/80', lightning: 'text-yellow-400', onchain: 'text-orange-500', + ecash: 'text-purple-400', fedimint: 'text-blue-400', ark: 'text-teal-400', +}[effectiveMethod.value])) + +const destDisplay = computed(() => { + const d = dest.value.trim() + return d.length > 64 ? `${d.slice(0, 38)}…${d.slice(-18)}` : d +}) + +/** 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) +} + +// An invoice-fixed amount always wins over the typed one; a sweep is priced +// at the whole balance. +const confirmAmount = computed(() => { + if (isSweep.value) return confirmBalance.value ?? 0 + if (effectiveMethod.value === 'lightning' && invoiceAmountSats.value !== null) return invoiceAmountSats.value + return amount.value > 0 ? Math.floor(amount.value) : 0 +}) +const balanceAfter = computed(() => (confirmBalance.value ?? 0) - confirmAmount.value) +const insufficient = computed(() => + confirmBalance.value !== null && confirmAmount.value > confirmBalance.value && !isSweep.value +) + +async function loadConfirmBalance() { + confirmBalance.value = null + try { + if (effectiveMethod.value === 'ecash') { + const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance' }) + confirmBalance.value = res.balance_sats ?? 0 + } else if (effectiveMethod.value === 'fedimint') { + const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance' }) + confirmBalance.value = res.balance_sats ?? 0 + } else { + const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo' }) + confirmBalance.value = effectiveMethod.value === 'onchain' + ? (res.balance_sats ?? 0) + : (res.channel_balance_sats ?? 0) + } + } catch { + confirmBalance.value = null // balance preview is best-effort; send still guarded server-side + } +} + +function review() { + error.value = '' + const method = effectiveMethod.value + const d = dest.value.trim() + if (method === 'lightning') { + if (!d) { error.value = t('web5.pasteInvoice'); return } + invoiceAmountSats.value = parseBolt11AmountSats(d) + } else { + invoiceAmountSats.value = null + if (method === 'ark' && !d) { error.value = 'Enter an Ark address, invoice or lightning address'; return } + if (method === 'onchain' && !d) { error.value = t('web5.enterBitcoinAddress'); return } + } + if (!isSweep.value && confirmAmount.value <= 0 && invoiceAmountSats.value === null) { + error.value = t('sendBitcoin.amountSats'); return + } + void loadConfirmBalance() + confirming.value = true +} + function close() { error.value = '' resultTxid.value = '' resultHash.value = '' resultArk.value = '' ecashToken.value = '' + confirming.value = false emit('close') } @@ -162,7 +295,8 @@ watch(ecashToken, async (token) => { async function send() { if (processing.value) return - if (!amount.value && !isSweep.value) return + // Zero typed amount is fine when the invoice fixes the amount or we sweep. + if (!amount.value && !isSweep.value && invoiceAmountSats.value === null) return processing.value = true error.value = '' ecashToken.value = '' @@ -212,6 +346,8 @@ async function send() { resultTxid.value = res.txid } emit('sent') + // Back to the form pane so the success/token panes are visible. + confirming.value = false } catch (err: unknown) { error.value = err instanceof Error ? err.message : t('web5.sendFailed') } finally { diff --git a/neode-ui/src/components/WalletScanModal.vue b/neode-ui/src/components/WalletScanModal.vue index 38a3759f..650588dc 100644 --- a/neode-ui/src/components/WalletScanModal.vue +++ b/neode-ui/src/components/WalletScanModal.vue @@ -43,7 +43,22 @@
-
+ +
+ + + +

How do you want to read the QR?

+ + +
+ +
@@ -73,17 +88,20 @@ -
+ + +

{{ scanStatus || 'Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code' }} @@ -282,7 +300,9 @@ function goBack() { if (pane.value === 'amount') { error.value = '' goTo('scan', 'back') - nextTick(() => { if (!liveCameraUnavailable.value) startScanning() }) + // 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() } @@ -295,6 +315,16 @@ const isScanning = ref(false) // 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() { + scanChoice.value = 'camera' + void nextTick(() => startScanning()) +} const scanStatus = ref('') const scanStatusIsError = ref(false) const cameraError = ref(false) @@ -709,7 +739,8 @@ function close() { watch(() => props.show, (open) => { if (open) { resetAll() - nextTick(() => { if (!liveCameraUnavailable.value) startScanning() }) + // No auto-start: the chooser interstitial owns the first move every time. + scanChoice.value = 'unset' } else { stopScanning() }