diff --git a/src/modules/wallet/components/SendDialog.vue b/src/modules/wallet/components/SendDialog.vue index 1026275..20631e3 100644 --- a/src/modules/wallet/components/SendDialog.vue +++ b/src/modules/wallet/components/SendDialog.vue @@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue' import { useForm } from 'vee-validate' import { toTypedSchema } from '@vee-validate/zod' import * as z from 'zod' +import { decode as decodeBolt11 } from 'light-bolt11-decoder' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' @@ -21,6 +22,21 @@ interface Emits { (e: 'update:open', value: boolean): void } +interface ParsedInvoice { + amount: number // in sats + description: string + bolt11: string + paymentHash: string + expiry?: number +} + +interface ParsedLNURL { + type: 'lnurl' | 'lightning-address' + minSendable: number // in msats + maxSendable: number // in msats + description: string +} + const props = defineProps() const emit = defineEmits() @@ -28,14 +44,102 @@ const emit = defineEmits() const walletService = injectService(SERVICE_TOKENS.WALLET_SERVICE) as any const toastService = injectService(SERVICE_TOKENS.TOAST_SERVICE) as any -// Form validation schema -const formSchema = toTypedSchema(z.object({ - destination: z.string().min(1, "Destination is required"), - amount: z.number().min(1, "Amount must be at least 1 sat").max(1000000, "Amount too large"), - comment: z.string().optional() -})) +// Payment parsing state +const parsedInvoice = ref(null) +const parsedLNURL = ref(null) +const paymentType = ref<'bolt11' | 'lnurl' | 'lightning-address' | 'unknown'>('unknown') -// Form setup +// Payment type detection functions +function isLNURL(input: string): boolean { + const lower = input.toLowerCase() + return lower.startsWith('lnurl1') || + lower.startsWith('lnurlp://') || + lower.startsWith('lnurlw://') || + lower.startsWith('lnurlauth://') +} + +function isLightningAddress(input: string): boolean { + return /^[\w.+-~_]+@[\w.+-~_]+$/.test(input) +} + +function isBolt11(input: string): boolean { + const lower = input.toLowerCase() + return lower.startsWith('lnbc') || lower.startsWith('lntb') || lower.startsWith('lnbcrt') +} + +function parseBolt11Invoice(bolt11: string): ParsedInvoice | null { + try { + const decoded = decodeBolt11(bolt11) + + // Extract amount from millisatoshis + const amountMsat = decoded.sections.find(s => s.name === 'amount')?.value + const amount = amountMsat ? Number(amountMsat) / 1000 : 0 + + // Extract description + const description = decoded.sections.find(s => s.name === 'description')?.value || '' + + // Extract payment hash + const paymentHash = decoded.sections.find(s => s.name === 'payment_hash')?.value || '' + + return { + amount, + description, + bolt11, + paymentHash, + } + } catch (error) { + console.error('Failed to decode BOLT11 invoice:', error) + return null + } +} + +function parsePaymentDestination(destination: string) { + // Clear previous parsing results + parsedInvoice.value = null + parsedLNURL.value = null + paymentType.value = 'unknown' + + if (!destination.trim()) return + + const cleanDest = destination.trim() + + if (isBolt11(cleanDest)) { + paymentType.value = 'bolt11' + parsedInvoice.value = parseBolt11Invoice(cleanDest) + } else if (isLNURL(cleanDest)) { + paymentType.value = 'lnurl' + // LNURL parsing would require API call to resolve + // For now, just mark as LNURL type + } else if (isLightningAddress(cleanDest)) { + paymentType.value = 'lightning-address' + // Lightning address parsing would require API call + // For now, just mark as lightning address type + } +} + +// Dynamic form validation schema based on payment type +const formSchema = computed(() => { + const baseSchema = { + destination: z.string().min(1, "Destination is required"), + comment: z.string().optional() + } + + // Only require amount for LNURL, Lightning addresses, or zero-amount invoices + const needsAmountInput = paymentType.value === 'lnurl' || + paymentType.value === 'lightning-address' || + (paymentType.value === 'bolt11' && parsedInvoice.value?.amount === 0) + + if (needsAmountInput) { + return toTypedSchema(z.object({ + ...baseSchema, + amount: z.number().min(1, "Amount must be at least 1 sat").max(1000000, "Amount too large") + })) + } else { + return toTypedSchema(z.object(baseSchema)) + } +}) + +// Form setup with dynamic schema const form = useForm({ validationSchema: formSchema, initialValues: { @@ -55,17 +159,48 @@ watch(() => props.initialDestination, (newDestination) => { } }, { immediate: true }) +// Watch destination changes to parse payment type +watch(() => values.destination, (newDestination) => { + parsePaymentDestination(newDestination) +}, { immediate: true }) + // State const isSending = computed(() => walletService?.isSendingPayment?.value || false) const showScanner = ref(false) const error = computed(() => walletService?.error?.value) +// Computed properties for UI logic +const showAmountField = computed(() => { + return paymentType.value === 'lnurl' || + paymentType.value === 'lightning-address' || + (paymentType.value === 'bolt11' && parsedInvoice.value?.amount === 0) +}) + +const displayAmount = computed(() => { + if (parsedInvoice.value && parsedInvoice.value.amount > 0) { + return parsedInvoice.value.amount + } + return null +}) + +const displayDescription = computed(() => { + return parsedInvoice.value?.description || '' +}) + +const effectiveAmount = computed(() => { + // Use parsed invoice amount if available, otherwise use form input + if (parsedInvoice.value && parsedInvoice.value.amount > 0) { + return parsedInvoice.value.amount + } + return values.amount +}) + // Methods const onSubmit = form.handleSubmit(async (formValues) => { try { const success = await walletService.sendPayment({ destination: formValues.destination, - amount: formValues.amount, + amount: effectiveAmount.value, // Use computed effective amount comment: formValues.comment || undefined }) @@ -108,15 +243,21 @@ function handleScanResult(result: string) { // Determine destination type helper text const destinationType = computed(() => { - const dest = values.destination?.toLowerCase() || '' - if (dest.startsWith('lnbc') || dest.startsWith('lntb')) { - return 'Lightning Invoice' - } else if (dest.includes('@')) { - return 'Lightning Address' - } else if (dest.startsWith('lnurl')) { - return 'LNURL' + switch (paymentType.value) { + case 'bolt11': + if (parsedInvoice.value) { + const amountText = parsedInvoice.value.amount > 0 ? + ` (${parsedInvoice.value.amount.toLocaleString()} sats)` : ' (zero amount)' + return `Lightning Invoice${amountText}` + } + return 'Lightning Invoice' + case 'lightning-address': + return 'Lightning Address' + case 'lnurl': + return 'LNURL' + default: + return '' } - return '' }) @@ -163,7 +304,21 @@ const destinationType = computed(() => { - + +
+
+

Lightning Invoice

+
+ {{ displayAmount.toLocaleString() }} sats +
+
+
+ Description: {{ displayDescription }} +
+
+ + + Amount (sats) * @@ -174,7 +329,11 @@ const destinationType = computed(() => { v-bind="componentField" /> - Amount to send in satoshis + + Amount to send to Lightning address + Amount to send via LNURL + Amount to send in satoshis +