feat(wallet): receive modal sees the payment land — clock, txid, Done
Demo images / Build & push demo images (push) Successful in 5m30s

While the on-chain QR is showing, poll lnd.gettransactions (5s). The
address is fresh from lnd.newaddress, so any incoming wallet tx paying it
is this receive. First sighting swaps the QR for a success view — pulsing
clock at 0-conf with the amount and a middle-ellipsized txid + copy, a
single Done button — and emits 'received' so the balance refreshes. The
watcher keeps going until the first confirmation upgrades the clock to a
check, and dies with the modal (close, hide, unmount).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-11 05:42:15 -04:00
co-authored by Claude Fable 5
parent 8a55ae73a6
commit 35e2406a41
3 changed files with 125 additions and 15 deletions
+117 -15
View File
@@ -31,19 +31,50 @@
<!-- On-chain -->
<div v-if="receiveMethod === 'onchain'">
<div v-if="note" class="mb-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20 text-sm text-white/80 leading-relaxed">
{{ note }}
</div>
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p>
<p class="text-sm font-mono text-white/90 break-all">{{ onchainAddress }}</p>
<CopyButton :value="onchainAddress" :label="t('common.copy')" class="mt-2" />
</div>
<div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
<p v-if="processing" class="text-xs text-white/40">Checking Lightning wallet readiness...</p>
<!-- Payment detected: the QR did its job show the outcome -->
<div v-if="paymentSeen" class="mb-3 p-6 bg-white/5 rounded-lg text-center">
<div class="flex justify-center mb-4">
<div
class="w-16 h-16 rounded-full flex items-center justify-center"
:class="paymentSeen.confirmations > 0 ? 'bg-green-500/15' : 'bg-orange-500/15 animate-pulse'"
>
<!-- Check once confirmed, clock while in the mempool -->
<svg v-if="paymentSeen.confirmations > 0" class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
<svg v-else class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</div>
<p class="text-lg font-semibold text-white mb-1">
{{ paymentSeen.confirmations > 0 ? t('receiveBitcoin.paymentConfirmed') : t('receiveBitcoin.paymentBroadcast') }}
</p>
<p v-if="paymentSeen.amountSats > 0" class="text-2xl font-semibold text-white/95 mb-2">
{{ paymentSeen.amountSats.toLocaleString() }} sats
</p>
<p v-if="paymentSeen.confirmations === 0" class="text-sm text-white/50 mb-3 max-w-md mx-auto">
{{ t('receiveBitcoin.paymentBroadcastHint') }}
</p>
<p class="text-xs text-white/50 mb-1">{{ t('receiveBitcoin.transactionId') }}</p>
<p class="text-xs font-mono text-white/80" :title="paymentSeen.txid">{{ midTxid(paymentSeen.txid) }}</p>
<CopyButton :value="paymentSeen.txid" :label="t('common.copy')" class="mt-2" />
</div>
<template v-else>
<div v-if="note" class="mb-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20 text-sm text-white/80 leading-relaxed">
{{ note }}
</div>
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p>
<p class="text-sm font-mono text-white/90 break-all">{{ onchainAddress }}</p>
<CopyButton :value="onchainAddress" :label="t('common.copy')" class="mt-2" />
</div>
<div v-else class="mb-3 text-center">
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
<p v-if="processing" class="text-xs text-white/40">Checking Lightning wallet readiness...</p>
</div>
</template>
</div>
<!-- Ark -->
@@ -70,7 +101,11 @@
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
<div class="flex gap-3">
<!-- Once the payment is seen there is nothing left to do here -->
<div v-if="paymentSeen" class="flex">
<button @click="close" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium">{{ t('common.done') }}</button>
</div>
<div v-else class="flex gap-3">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="$emit('scan')" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -86,7 +121,7 @@
</template>
<script setup lang="ts">
import { ref, nextTick, watch } from 'vue'
import { ref, nextTick, watch, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
@@ -107,7 +142,11 @@ const props = defineProps<{
const emit = defineEmits<{ close: []; received: []; scan: [] }>()
watch(() => props.show, (open) => {
if (!open) return
if (!open) {
stopWatchingPayment()
return
}
paymentSeen.value = null
// Blank slate on every open: a leftover amount/memo/token or a previous
// invoice quietly carrying into a new receive flow is exactly the stale-
// state class the operator flagged on the send modal (2026-08-05).
@@ -140,6 +179,65 @@ const arkQrCanvas = ref<HTMLCanvasElement | null>(null)
const processing = ref(false)
const error = ref('')
// ── On-chain payment detection ────────────────────────────────────────────
// The generated address is FRESH (lnd.newaddress), so any incoming wallet
// transaction paying it is this receive — no baseline bookkeeping needed.
// Poll while the QR is showing; flip to the success view on first sight
// (0-conf, clock), keep polling gently until the first confirmation
// upgrades it to a check, then stop.
const paymentSeen = ref<null | { txid: string; amountSats: number; confirmations: number }>(null)
let watchTimer: ReturnType<typeof setInterval> | null = null
function midTxid(txid: string): string {
return txid.length > 24 ? `${txid.slice(0, 10)}${txid.slice(-10)}` : txid
}
function stopWatchingPayment() {
if (watchTimer) {
clearInterval(watchTimer)
watchTimer = null
}
}
function startWatchingPayment() {
stopWatchingPayment()
watchTimer = setInterval(() => void checkForPayment(), 5000)
}
async function checkForPayment() {
if (!props.show || !onchainAddress.value) {
stopWatchingPayment()
return
}
try {
const res = await rpcClient.call<{
transactions: Array<{
tx_hash: string
amount: number
num_confirmations: number
dest_addresses: string[]
direction: string
}>
}>({ method: 'lnd.gettransactions' })
const hit = (res.transactions || []).find(
(tx) => tx.direction === 'incoming' && (tx.dest_addresses || []).includes(onchainAddress.value),
)
if (!hit) return
const firstSighting = !paymentSeen.value
paymentSeen.value = {
txid: hit.tx_hash,
amountSats: hit.amount,
confirmations: hit.num_confirmations,
}
if (firstSighting) emit('received')
if (hit.num_confirmations > 0) stopWatchingPayment()
} catch {
// Transient poll failure (daemon busy, LND mid-restart) — keep watching.
}
}
onUnmounted(stopWatchingPayment)
async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix = '') {
if (!canvas || !data) return
try {
@@ -153,6 +251,8 @@ async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix =
}
function close() {
stopWatchingPayment()
paymentSeen.value = null
invoiceResult.value = ''
onchainAddress.value = ''
arkAddress.value = ''
@@ -184,6 +284,8 @@ async function receive() {
throw new Error('LND did not return a Bitcoin address')
}
onchainAddress.value = res.address
paymentSeen.value = null
startWatchingPayment()
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
} else if (receiveMethod.value === 'ark') {
const res = await rpcClient.call<{ address: string }>({ method: 'wallet.ark-address' })
+4
View File
@@ -764,6 +764,10 @@
"memoPlaceholder": "Payment for...",
"invoiceShareLabel": "Invoice (share with sender):",
"yourBitcoinAddress": "Your Bitcoin address:",
"paymentBroadcast": "Payment on its way",
"paymentBroadcastHint": "The transaction has been broadcast and is waiting for its first confirmation. You can close this — the funds arrive on their own.",
"paymentConfirmed": "Payment confirmed",
"transactionId": "Transaction ID",
"pasteEcashToken": "Paste ecash token",
"processing": "Processing...",
"generateAddress": "Generate Address",
+4
View File
@@ -751,6 +751,10 @@
"memoPlaceholder": "Pago por...",
"invoiceShareLabel": "Factura (compartir con el remitente):",
"yourBitcoinAddress": "Su direcci\u00f3n Bitcoin:",
"paymentBroadcast": "Pago en camino",
"paymentBroadcastHint": "La transacci\u00f3n se ha difundido y espera su primera confirmaci\u00f3n. Puede cerrar esta ventana \u2014 los fondos llegar\u00e1n solos.",
"paymentConfirmed": "Pago confirmado",
"transactionId": "ID de transacci\u00f3n",
"pasteEcashToken": "Pegar token Ecash",
"processing": "Procesando...",
"generateAddress": "Generar direcci\u00f3n",