feat(wallet): receive modal sees the payment land — clock, txid, Done
Demo images / Build & push demo images (push) Successful in 5m30s
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:
co-authored by
Claude Fable 5
parent
8a55ae73a6
commit
35e2406a41
@@ -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' })
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user