Files
archy/neode-ui/src/components/SendBitcoinModal.vue
T
archipelagoandClaude Fable 5 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

719 lines
31 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>
<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-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>
<span class="text-sm font-medium" :class="methodColor">{{ methodLabel }}</span>
</div>
<div v-if="dest.trim()" class="mb-1">
<p class="text-xs text-white/50 mb-1">{{ effectiveMethod === 'lightning' ? 'Invoice' : effectiveMethod === 'ark' ? 'Destination' : 'Address' }}</p>
<p class="text-xs font-mono text-white/80 break-all">{{ destDisplay }}</p>
</div>
<p v-else class="text-xs text-white/60">Creates a {{ methodLabel }} token you can share sats leave your balance when it's redeemed.</p>
</div>
<!-- Live balance impact -->
<div class="mb-3 p-3 bg-white/5 rounded-lg space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-xs text-white/50">{{ methodLabel }} balance</span>
<span class="text-sm font-medium text-white/80">{{ confirmBalance === null ? '' : confirmBalance.toLocaleString() + ' sats' }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-white/50 flex items-center gap-2">
Amount
<span v-if="invoiceAmountSats !== null" class="text-[11px] px-2 py-0.5 rounded-full bg-white/10 text-white/50">set by invoice</span>
</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'">
{{ confirmBalance === null ? '' : balanceAfter.toLocaleString() + ' sats' }}
</span>
</div>
</div>
<p v-if="insufficient" class="text-xs text-red-400 mb-3">Not enough {{ methodLabel }} balance for this amount.</p>
<p v-else-if="isSweep" class="text-xs text-white/50 mb-3">Sweeps the entire on-chain balance minus network fees.</p>
<p v-else-if="effectiveMethod === 'onchain'" class="text-xs text-white/50 mb-3">Network fees are deducted on top of the amount.</p>
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
<div class="flex gap-3">
<button @click="confirming = false" :disabled="processing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Back</button>
<button @click="send" :disabled="processing || insufficient || (confirmAmount <= 0 && !isSweep)" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ processing ? t('common.sending') : 'Confirm & Send' }}
</button>
</div>
</template>
<template v-else>
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
v-for="m in (['lightning', 'onchain', 'ecash', 'fedimint', 'ark'] as const)"
:key="m"
@click="sendMethod = m"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium capitalize transition-colors"
:class="sendMethod === m ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ m === 'onchain' ? t('sendBitcoin.onChain') : m === 'lightning' ? t('sendBitcoin.lightning') : m === 'ecash' ? 'Cashu' : m === 'fedimint' ? 'Fedi' : 'Ark' }}</button>
</div>
<div v-if="sendMethod === 'auto'" class="mb-3 p-2 bg-white/5 rounded-lg">
<p class="text-xs text-white/50">{{ t('sendBitcoin.autoMethodDesc') }}</p>
</div>
<div class="mb-3">
<div class="flex items-center justify-between mb-1">
<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'"
@click="toggleSendAll"
class="text-xs px-2 py-0.5 rounded border transition-colors"
:class="sendAll
? 'bg-orange-500/20 border-orange-500/40 text-orange-300'
: 'bg-white/5 border-white/15 text-white/60 hover:text-white/90'"
>
Send all funds
</button>
</div>
<input
v-model.number="amountEntry"
type="number"
: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"
/>
<p v-if="sendAll" class="text-xs text-white/50 mt-1">
Sweeps your entire on-chain balance{{ onchainBalance !== null ? ` (~${onchainBalance.toLocaleString()} sats)` : '' }} minus network fees.
</p>
<p v-else-if="pastedInvoiceAmount !== null" class="text-xs text-white/50 mt-1">
This invoice fixes the amount — nothing to type, just review and send.
</p>
<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">
<div class="flex items-center justify-between mb-1">
<label class="text-white/60 text-sm">
{{ effectiveMethod === 'lightning' ? t('sendBitcoin.lightningInvoice') : effectiveMethod === 'ark' ? 'Ark address, invoice or lightning address' : t('sendBitcoin.bitcoinAddress') }}
</label>
<button
v-if="canReadClipboard"
@click="pasteFromClipboard"
class="text-xs px-2 py-0.5 rounded border bg-white/5 border-white/15 text-white/60 hover:text-white/90 transition-colors"
>
{{ effectiveMethod === 'lightning' ? 'Paste invoice' : 'Paste' }}
</button>
</div>
<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
(animated multi-frame not needed: qrcode handles these sizes). -->
<div class="flex justify-center my-2">
<canvas ref="tokenQrCanvas" class="rounded-lg bg-white p-2"></canvas>
</div>
<p class="text-xs font-mono text-white/80 break-all">{{ ecashToken }}</p>
<button @click="copyText(ecashToken)" class="mt-2 text-xs text-orange-400 hover:text-orange-300">{{ t('common.copy') }}</button>
</div>
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
<div 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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
Scan
</button>
<button @click="review" :disabled="processing" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ t('common.send') }}
</button>
</div>
</template>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
const { t } = useI18n()
const props = defineProps<{ show: boolean }>()
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')
// --- 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('')
// 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)
const sendAll = ref(false)
const onchainBalance = ref<number | null>(null)
const isSweep = computed(() => sendMethod.value === 'onchain' && sendAll.value)
function toggleSendAll() {
sendAll.value = !sendAll.value
if (sendAll.value && onchainBalance.value === null) {
rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
.then((res) => { onchainBalance.value = res.balance_sats || 0 })
.catch(() => { /* balance hint is best-effort */ })
}
}
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false })
// --- 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
async function pasteFromClipboard() {
try {
const text = (await navigator.clipboard.readText()).trim()
if (text) dest.value = text
} catch {
// Permission denied — user can long-press/Ctrl+V into the field instead.
}
}
const effectiveMethod = computed(() => {
if (sendMethod.value !== 'auto') return sendMethod.value
const amt = amount.value || 0
if (amt <= 0) return 'lightning'
if (amt < 1000) return 'ecash'
if (amt > 500000) return 'onchain'
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.
const confirming = ref(false)
const confirmBalance = ref<number | null>(null)
const invoiceAmountSats = ref<number | null>(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
}
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 = ''
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(() => {})
}
const tokenQrCanvas = ref<HTMLCanvasElement | null>(null)
watch(ecashToken, async (token) => {
if (!token) return
await nextTick()
if (!tokenQrCanvas.value) return
try {
const QRCode = await import('qrcode')
await QRCode.toCanvas(tokenQrCanvas.value, token, { width: 220, margin: 1 })
} catch { /* QR is a convenience — the copyable text is authoritative */ }
})
async function send() {
if (processing.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 = ''
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 }
await rpcClient.call<{ sent: boolean }>({
method: 'wallet.ark-send',
params: { destination: dest.value.trim(), amount_sats: amount.value },
// Ark sends can wait on round participation.
timeout: 130000,
})
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',
params: { amount_sats: amount.value },
})
ecashToken.value = res.token
} else if (method === 'fedimint') {
const res = await rpcClient.call<{ token: string }>({
method: 'wallet.fedimint-send',
params: { amount_sats: amount.value },
timeout: 60000,
})
ecashToken.value = res.token
} else if (method === 'lightning') {
if (!dest.value.trim()) { error.value = t('web5.pasteInvoice'); return }
// 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, ...resolvedFeeParams.value }
: { addr: dest.value.trim(), amount: amount.value, ...resolvedFeeParams.value },
})
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')
// 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')
} finally {
processing.value = false
}
}
</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>