- SendBitcoinModal crashed AT MOUNT on device ('Cannot access R before
initialization'): the invoice-first block's watch() evaluates its
computed source at setup, which read effectiveMethod while still in
its temporal dead zone (declared 20 lines later). Send button did
nothing and the failure poisoned sibling modal data. Block moved below
effectiveMethod, with a comment so it can't drift back.
- Cold start injected 0px safe-area vars (onPageFinished can beat window
attachment; rootWindowInsets null) — UI lost top/bottom margins and
the tab bar sat in the gesture zone eating taps. Injection extracted
to injectSafeAreaVars(), re-fired from an OnApplyWindowInsetsListener
when real insets arrive, on page finish, and on reattach.
- MeshLoadingScreen wears the proper brand: pixel 'a' in the black
circle-container + F*CK IPS MESH wordmark.
Served APK: 0.5.10 (vc30). Reproduced+diagnosed live on-device.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
406 lines
18 KiB
Vue
406 lines
18 KiB
Vue
<template>
|
||
<BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
|
||
<!-- ============ CONFIRM PANE (second step, mirrors the scan flow) ============ -->
|
||
<template v-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 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">
|
||
<label class="text-white/60 text-sm">{{ t('sendBitcoin.amountSats') }}</label>
|
||
<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="amount"
|
||
type="number"
|
||
min="1"
|
||
:placeholder="sendAll ? '' : pastedInvoiceAmount !== null ? '' : '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>
|
||
</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>
|
||
|
||
<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="resultTxid" class="mb-3 alert-success">
|
||
<p class="text-xs">{{ t('sendBitcoin.sentTx', { txid: resultTxid }) }}</p>
|
||
</div>
|
||
<div v-if="resultHash" class="mb-3 alert-success">
|
||
<p class="text-xs">{{ t('sendBitcoin.paidHash', { hash: resultHash }) }}</p>
|
||
</div>
|
||
<div v-if="resultArk" class="mb-3 alert-success">
|
||
<p class="text-xs">{{ resultArk }}</p>
|
||
</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')
|
||
const amount = ref<number>(0)
|
||
const dest = ref('')
|
||
const processing = ref(false)
|
||
const error = ref('')
|
||
const resultTxid = ref('')
|
||
const resultHash = ref('')
|
||
const resultArk = ref('')
|
||
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 })
|
||
|
||
// 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
|
||
}
|
||
void loadConfirmBalance()
|
||
confirming.value = true
|
||
}
|
||
|
||
function close() {
|
||
error.value = ''
|
||
resultTxid.value = ''
|
||
resultHash.value = ''
|
||
resultArk.value = ''
|
||
ecashToken.value = ''
|
||
confirming.value = false
|
||
emit('close')
|
||
}
|
||
|
||
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 = ''
|
||
resultTxid.value = ''
|
||
resultHash.value = ''
|
||
resultArk.value = ''
|
||
|
||
const method = effectiveMethod.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,
|
||
})
|
||
resultArk.value = `Sent ${amount.value.toLocaleString()} sats via Ark`
|
||
} 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 }
|
||
const res = await rpcClient.call<{ payment_hash: string }>({
|
||
method: 'lnd.payinvoice',
|
||
params: { payment_request: dest.value.trim() },
|
||
})
|
||
resultHash.value = res.payment_hash
|
||
} 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 }
|
||
: { addr: dest.value.trim(), amount: amount.value },
|
||
})
|
||
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 {
|
||
processing.value = false
|
||
}
|
||
}
|
||
</script>
|