Browse Source

reinstate lightning

imwald
Silberengel 3 weeks ago
parent
commit
b8191cccb6
  1. 51
      src/components/NoteStats/ZapButton.tsx
  2. 36
      src/components/PaytoDialog/LightningInvoiceSection.tsx
  3. 13
      src/components/PaytoDialog/index.tsx
  4. 39
      src/components/ProfileZapButton/index.tsx
  5. 12
      src/components/Sidebar/index.tsx
  6. 33
      src/components/ZapDialog/Nip57QuickZapButton.tsx
  7. 9
      src/components/ZapDialog/PostPaymentMessagePrompt.tsx
  8. 11
      src/components/ZapDialog/SuperchatRequestForm.tsx
  9. 37
      src/components/ZapDialog/index.tsx
  10. 2
      src/constants.ts
  11. 123
      src/hooks/useNip57QuickZap.ts
  12. 6
      src/i18n/locales/en.ts
  13. 4
      src/lib/lightning-zap-amount.test.ts
  14. 5
      src/lib/lightning.ts
  15. 35
      src/lib/merge-payment-methods.ts
  16. 45
      src/lib/nip57-zap.ts
  17. 4
      src/lib/post-payment-context.ts
  18. 21
      src/lib/wallet-connection-details.ts
  19. 32
      src/lib/webln-payment.ts
  20. 25
      src/pages/secondary/WalletPage/IncludePublicZapReceiptSwitch.tsx
  21. 2
      src/pages/secondary/WalletPage/LightningAddressInput.tsx
  22. 37
      src/pages/secondary/WalletPage/WalletConnectionDetails.tsx
  23. 4
      src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx
  24. 10
      src/providers/ZapProvider.tsx
  25. 209
      src/services/lightning.service.ts

51
src/components/NoteStats/ZapButton.tsx

@ -1,4 +1,6 @@ @@ -1,4 +1,6 @@
import { useNoteStatsById } from '@/hooks/useNoteStatsById'
import { useLongPressAction } from '@/hooks/use-long-press-action'
import { useNip57QuickZap } from '@/hooks/useNip57QuickZap'
import { recipientHasAnyPaymentOptions } from '@/lib/merge-payment-methods'
import {
buildRecipientPaymentData,
@ -16,6 +18,7 @@ import { kinds, type Event } from 'nostr-tools' @@ -16,6 +18,7 @@ import { kinds, type Event } from 'nostr-tools'
import { Zap } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Skeleton } from '@/components/ui/skeleton'
import ZapDialog from '../ZapDialog'
import PostPaymentMessagePrompt from '../ZapDialog/PostPaymentMessagePrompt'
import { mergePostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context'
@ -210,13 +213,43 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB @@ -210,13 +213,43 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
}
}, [authorPubkey, isSelf, feedProfileSyncKey, applyTipAvailability])
const recipientPaymentForZap = useMemo(
() =>
tipPaymentData ??
buildRecipientPaymentData(
null,
feedProfile && !feedProfile.batchPlaceholder ? feedProfile : null,
null
),
[tipPaymentData, feedProfile]
)
const { canQuickNip57Zap, sendQuickZap, zapping } = useNip57QuickZap({
recipientPubkey: event.pubkey,
referencedEvent: event,
recipientPayment: recipientPaymentForZap,
onPostPaymentRequest: handlePostPaymentRequest,
onZapDialogClose: () => setOpenPaymentDialog(false)
})
const longPressZap = useLongPressAction(() => sendQuickZap(), {
enabled: canQuickNip57Zap && !disable
})
const handleOpenPaymentMethods = (e: React.MouseEvent) => {
e.stopPropagation()
e.preventDefault()
if (longPressZap.consumeIfLongPress()) return
if (disable) return
setOpenPaymentDialog(true)
}
const zapButtonTitle = disable
? t('Zaps')
: canQuickNip57Zap
? t('Payment methods — long-press to zap')
: t('Payment methods')
return (
<>
<div className="flex h-full min-w-0 select-none items-center">
@ -224,14 +257,21 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB @@ -224,14 +257,21 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
type="button"
className={cn(
'group flex h-full items-center pl-3 pr-1',
disable ? 'cursor-not-allowed' : 'cursor-pointer'
disable || zapping ? 'cursor-not-allowed' : 'cursor-pointer'
)}
title={disable ? t('Zaps') : t('Payment methods')}
aria-label={disable ? t('Zaps') : t('Payment methods')}
disabled={disable}
title={zapButtonTitle}
aria-label={zapButtonTitle}
disabled={disable || zapping}
onClick={handleOpenPaymentMethods}
onPointerDown={longPressZap.onPointerDown}
onPointerUp={longPressZap.onPointerUp}
onPointerLeave={longPressZap.onPointerLeave}
onPointerCancel={longPressZap.onPointerCancel}
>
<Zap
{zapping ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : (
<Zap
className={cn(
hasZapped && 'fill-yellow-400',
disable
@ -242,6 +282,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB @@ -242,6 +282,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
)
)}
/>
)}
</button>
{showZapAmount ? (
<ZapCountHover noteStats={noteStats}>

36
src/components/PaytoDialog/LightningInvoiceSection.tsx

@ -11,6 +11,7 @@ import { @@ -11,6 +11,7 @@ import {
formatAmount,
formatSatsGrouped,
getAmountFromInvoice,
LN_INVOICE_COFFEE_PRESET_SATS,
LN_INVOICE_PRESET_SATS,
MIN_ZAP_SATS,
} from '@/lib/lightning'
@ -48,7 +49,11 @@ export default function LightningInvoiceSection({ @@ -48,7 +49,11 @@ export default function LightningInvoiceSection({
/** Fired when a BOLT11 invoice is created or cleared (for Phoenix / external wallet links). */
onBolt11InvoiceChange?: (invoice: string | null) => void
/** After a wallet payment succeeds (dialog stays open for the user to choose next steps). */
onPaymentFlowComplete?: (details?: { amountMsat: number; payto: string }) => void
onPaymentFlowComplete?: (details?: {
amountMsat: number
payto: string
messageDraft?: string
}) => void
}) {
const { t } = useTranslation()
const { defaultZapSats, isWalletConnected } = useZap()
@ -139,24 +144,29 @@ export default function LightningInvoiceSection({ @@ -139,24 +144,29 @@ export default function LightningInvoiceSection({
}
}
const paymentDetails = useMemo(
() => ({
amountMsat: clampZapSats(sats) * 1000,
payto: formatPaytoTagValue(buildPaytoUri('lightning', lightningAddress))
}),
[sats, lightningAddress]
)
const buildPaymentDetails = (pr: string) => {
let amountMsat = clampZapSats(sats) * 1000
try {
amountMsat = getAmountFromInvoice(pr) * 1000
} catch {
/* use form amount */
}
return {
amountMsat,
payto: formatPaytoTagValue(buildPaytoUri('lightning', lightningAddress)),
messageDraft: invoiceDescription?.trim() || description.trim() || undefined
}
}
const handlePay = async () => {
if (!invoice) return
try {
setPaying(true)
const result = await lightning.payInvoice(invoice, undefined, (flowResult) => {
if (flowResult) onPaymentFlowComplete?.(paymentDetails)
})
const result = await lightning.payInvoice(invoice, undefined)
if (!mountedRef.current) return
if (result) {
toast.success(t('Payment sent'))
onPaymentFlowComplete?.(buildPaymentDetails(invoice))
setInvoice(null)
setInvoiceDescription(null)
}
@ -210,19 +220,23 @@ export default function LightningInvoiceSection({ @@ -210,19 +220,23 @@ export default function LightningInvoiceSection({
>
{LN_INVOICE_PRESET_SATS.map((preset) => {
const active = sats === preset
const isCoffeePreset = preset === LN_INVOICE_COFFEE_PRESET_SATS
return (
<Button
key={preset}
type="button"
variant={active ? 'default' : 'outline'}
size="sm"
title={isCoffeePreset ? t('Buy them a coffee.') : undefined}
className={cn(
'h-8 min-w-0 px-0.5 text-xs tabular-nums sm:h-9 sm:px-1 sm:text-sm',
isCoffeePreset && 'col-span-2 gap-1',
active && 'ring-1 ring-amber-600/45 dark:ring-yellow-400/50'
)}
onClick={() => setSats(preset)}
>
{formatAmount(preset)}
{isCoffeePreset ? <span aria-hidden></span> : null}
</Button>
)
})}

13
src/components/PaytoDialog/index.tsx

@ -70,7 +70,7 @@ export default function PaytoDialog({ @@ -70,7 +70,7 @@ export default function PaytoDialog({
const [postPaymentOpen, setPostPaymentOpen] = useState(false)
const [postPaymentContext, setPostPaymentContext] = useState<PostPaymentContext | null>(null)
const [completedPaymentDetails, setCompletedPaymentDetails] = useState<
Partial<Pick<PostPaymentContext, 'amountMsat' | 'payto'>> | null
Partial<Pick<PostPaymentContext, 'amountMsat' | 'payto' | 'messageDraft'>> | null
>(null)
const info = getPaytoTypeInfo(type)
const label = info?.label ?? type
@ -116,9 +116,11 @@ export default function PaytoDialog({ @@ -116,9 +116,11 @@ export default function PaytoDialog({
)
if (onPostPaymentRequest) {
onPostPaymentRequest(built)
onOpenChange(false)
return
}
if (!offerTipNoticeOnClose) return
onOpenChange(false)
setPostPaymentContext(built)
setPostPaymentOpen(true)
},
@ -219,10 +221,11 @@ export default function PaytoDialog({ @@ -219,10 +221,11 @@ export default function PaytoDialog({
paytoUri={paytoUri}
onBolt11InvoiceChange={setBolt11Invoice}
onPaymentFlowComplete={(details) => {
setCompletedPaymentDetails({
amountMsat: details?.amountMsat,
payto: details?.payto
})
if (!details) return
setCompletedPaymentDetails(details)
if (canOfferPostPayment) {
requestAnimationFrame(() => openPostPaymentPrompt(details))
}
}}
/>
) : isLightning ? null : (

39
src/components/ProfileZapButton/index.tsx

@ -1,7 +1,12 @@ @@ -1,7 +1,12 @@
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { useLongPressAction } from '@/hooks/use-long-press-action'
import { useNip57QuickZap } from '@/hooks/useNip57QuickZap'
import { useRecipientPaymentData } from '@/hooks/useRecipientAlternativePayments'
import { useNostr } from '@/providers/NostrProvider'
import { Zap } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ZapDialog from '../ZapDialog'
export default function ProfileZapButton({
@ -13,10 +18,22 @@ export default function ProfileZapButton({ @@ -13,10 +18,22 @@ export default function ProfileZapButton({
openZapDialog?: boolean
setOpenZapDialog?: (open: boolean) => void
}) {
const { t } = useTranslation()
const { checkLogin } = useNostr()
const [internalOpen, setInternalOpen] = useState(false)
const open = setOpenZapDialog ? (openZapDialog ?? false) : internalOpen
const setOpen = setOpenZapDialog ?? setInternalOpen
const recipientPayment = useRecipientPaymentData(pubkey, true)
const { canQuickNip57Zap, sendQuickZap, zapping } = useNip57QuickZap({
recipientPubkey: pubkey,
recipientPayment,
onZapDialogClose: () => setOpen(false)
})
const longPressZap = useLongPressAction(() => sendQuickZap(), { enabled: canQuickNip57Zap })
const title = canQuickNip57Zap ? t('Payment methods — long-press to zap') : t('Payment methods')
return (
<>
@ -24,11 +41,27 @@ export default function ProfileZapButton({ @@ -24,11 +41,27 @@ export default function ProfileZapButton({
variant="secondary"
size="icon"
className="rounded-full"
onClick={() => checkLogin(() => setOpen(true))}
title={title}
aria-label={title}
disabled={zapping}
onClick={() => {
if (longPressZap.consumeIfLongPress()) return
checkLogin(() => setOpen(true))
}}
onPointerDown={longPressZap.onPointerDown}
onPointerUp={longPressZap.onPointerUp}
onPointerLeave={longPressZap.onPointerLeave}
onPointerCancel={longPressZap.onPointerCancel}
>
<Zap className="text-yellow-400" />
{zapping ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : (
<Zap className="text-yellow-400" />
)}
</Button>
{!setOpenZapDialog && <ZapDialog open={open} setOpen={setInternalOpen} pubkey={pubkey} />}
{!setOpenZapDialog && (
<ZapDialog open={open} setOpen={setInternalOpen} pubkey={pubkey} prefetchedPayment={recipientPayment} />
)}
</>
)
}

12
src/components/Sidebar/index.tsx

@ -23,15 +23,15 @@ export default function PrimaryPageSidebar() { @@ -23,15 +23,15 @@ export default function PrimaryPageSidebar() {
if (isSmallScreen) return null
return (
<div className="imwald-sidebar w-[4.8rem] xl:w-[15.6rem] flex flex-col pb-2 pt-4 px-2 xl:pl-4 xl:pr-6 justify-between h-full shrink-0">
<div className="imwald-sidebar flex h-full min-h-0 w-[4.8rem] shrink-0 flex-col overflow-hidden pb-2 pt-4 px-2 xl:w-[15.6rem] xl:pl-4 xl:pr-6">
<div className="imwald-sidebar__atmosphere" aria-hidden />
<div className="relative z-[1] flex min-h-0 flex-1 flex-col justify-between">
<div className="space-y-2">
<div className="mb-6 w-full min-w-0">
<div className="relative z-[1] flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="min-h-0 flex-1 space-y-2 overflow-x-hidden overflow-y-auto overscroll-contain">
<div className="mb-6 w-full min-w-0 shrink-0">
<Icon className="mx-auto xl:hidden" />
{/* Full-bleed banner at xl: span entire sidebar column (undo pl-4 + pr-6) */}
<div className="max-xl:hidden -ml-4 -mr-6 w-[calc(100%+2.5rem)] min-w-0">
<Logo className="h-auto w-full max-h-[5.5rem] max-w-full object-contain object-center" />
<Logo className="h-auto max-h-[5.5rem] w-full max-w-full object-contain object-center" />
</div>
</div>
<ReadOnlySessionIndicator variant="sidebar" />
@ -50,7 +50,7 @@ export default function PrimaryPageSidebar() { @@ -50,7 +50,7 @@ export default function PrimaryPageSidebar() {
<SidebarCalendarWeekWidget />
</div>
</div>
<div className="space-y-2">
<div className="shrink-0 space-y-2 pt-2">
<HelpAndAccountMenu variant="sidebar" />
<PaneModeToggle />
<DownloadDesktopSidebarButton />

33
src/components/ZapDialog/Nip57QuickZapButton.tsx

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { superchatLightningAccentClass } from '@/lib/superchat-ui'
import { cn } from '@/lib/utils'
import { Zap } from 'lucide-react'
export default function Nip57QuickZapButton({
label,
zapping,
onClick,
className
}: {
label: string
zapping: boolean
onClick: () => void
className?: string
}) {
return (
<Button
type="button"
className={cn('mb-3 w-full justify-start gap-2', className)}
onClick={onClick}
disabled={zapping}
>
{zapping ? (
<Skeleton className="size-4 shrink-0 rounded-full" aria-hidden />
) : (
<Zap className={cn('size-4 shrink-0', superchatLightningAccentClass)} aria-hidden />
)}
<span className="min-w-0 truncate">{label}</span>
</Button>
)
}

9
src/components/ZapDialog/PostPaymentMessagePrompt.tsx

@ -28,6 +28,11 @@ type Step = 'choice' | 'public-message' | 'superchat' @@ -28,6 +28,11 @@ type Step = 'choice' | 'public-message' | 'superchat'
const footerButtonClass = 'w-full min-w-0 sm:w-auto'
function postPaymentInitialStep(context?: PostPaymentContext | null): Step {
if (context?.amountMsat != null && context.payto) return 'superchat'
return 'choice'
}
export default function PostPaymentMessagePrompt({
open,
onOpenChange,
@ -46,9 +51,9 @@ export default function PostPaymentMessagePrompt({ @@ -46,9 +51,9 @@ export default function PostPaymentMessagePrompt({
useEffect(() => {
if (open) {
setStep('choice')
setStep(postPaymentInitialStep(paymentContext))
}
}, [open, recipientPubkey])
}, [open, recipientPubkey, paymentContext?.amountMsat, paymentContext?.payto, paymentContext?.messageDraft])
useEffect(() => {
if (!open || step !== 'choice') return

11
src/components/ZapDialog/SuperchatRequestForm.tsx

@ -35,13 +35,18 @@ export default function SuperchatRequestForm({ @@ -35,13 +35,18 @@ export default function SuperchatRequestForm({
const { t } = useTranslation()
const { publish, checkLogin, pubkey: selfPubkey } = useNostr()
const [message, setMessage] = useState('')
const [amountSats, setAmountSats] = useState(() =>
paymentContext?.amountMsat ? clampZapSats(Math.floor(paymentContext.amountMsat / 1000)) : 0
)
const [amountSats, setAmountSats] = useState(0)
const [minPow, setMinPow] = useState(0)
const [sending, setSending] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
setMessage(paymentContext?.messageDraft ?? '')
setAmountSats(
paymentContext?.amountMsat ? clampZapSats(Math.floor(paymentContext.amountMsat / 1000)) : 0
)
}, [paymentContext?.messageDraft, paymentContext?.amountMsat])
const amountMsat = amountSats > 0 ? clampZapSats(amountSats) * 1000 : undefined
useEffect(() => {

37
src/components/ZapDialog/index.tsx

@ -15,6 +15,7 @@ import { @@ -15,6 +15,7 @@ import {
import PaymentMethodsSection from '@/components/PaymentMethodsSection'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import { useNip57QuickZap } from '@/hooks/useNip57QuickZap'
import { useSenderPaytoTypes } from '@/hooks/useSenderPaytoTypes'
import {
mergeRecipientPaymentData,
@ -33,6 +34,7 @@ import { NostrEvent } from 'nostr-tools' @@ -33,6 +34,7 @@ import { NostrEvent } from 'nostr-tools'
import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PostPaymentMessagePrompt from './PostPaymentMessagePrompt'
import Nip57QuickZapButton from './Nip57QuickZapButton'
export default function ZapDialog({
open,
@ -93,18 +95,33 @@ export default function ZapDialog({ @@ -93,18 +95,33 @@ export default function ZapDialog({
return groupPaymentMethodsForDisplay(merged, senderPaytoTypes)
}, [recipientPayment, senderPaytoTypes])
const { canQuickNip57Zap, quickZapLabel, sendQuickZap, zapping } = useNip57QuickZap({
recipientPubkey: pubkey,
referencedEvent: event,
recipientPayment,
onPostPaymentRequest: openPostPaymentPrompt,
onZapDialogClose: () => setOpen(false)
})
const dialogTitle = t('Payment methods')
const body =
paymentGroups.length > 0 ? (
<PaymentMethodsSection
groups={paymentGroups}
recipientPubkey={pubkey}
referencedEvent={event}
offerTipNoticeOnClose={false}
onPostPaymentRequest={openPostPaymentPrompt}
title={t('Payment methods')}
className="rounded-lg border border-border bg-muted/40 p-3 min-w-0"
/>
paymentGroups.length > 0 || canQuickNip57Zap ? (
<>
{canQuickNip57Zap ? (
<Nip57QuickZapButton label={quickZapLabel} zapping={zapping} onClick={sendQuickZap} />
) : null}
{paymentGroups.length > 0 ? (
<PaymentMethodsSection
groups={paymentGroups}
recipientPubkey={pubkey}
referencedEvent={event}
offerTipNoticeOnClose={false}
onPostPaymentRequest={openPostPaymentPrompt}
title={t('Payment methods')}
className="rounded-lg border border-border bg-muted/40 p-3 min-w-0"
/>
) : null}
</>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">
{t('No payment methods available for this profile')}

2
src/constants.ts

@ -639,7 +639,7 @@ export const ExtendedKind = { @@ -639,7 +639,7 @@ export const ExtendedKind = {
EVENTS_I_MUTED_NOTIFICATIONS_LIST: 19132
}
/** WebLN wallet connect + pay BOLT11 from LNURL-pay invoices (no NIP-57 zaps). */
/** WebLN wallet connect + pay BOLT11; NIP-57 zaps when LNURL-pay supports nostr. */
export const LIGHTNING_WALLET_PAY_ENABLED = true
/** Kind 0 + NIP-A3 payment: publish to profile mirrors, full outbox (NIP-65 + HTTP + cache), and IndexedDB. */

123
src/hooks/useNip57QuickZap.ts

@ -0,0 +1,123 @@ @@ -0,0 +1,123 @@
import { buildOrderedZapLightningAddresses } from '@/lib/merge-payment-methods'
import { mergePostPaymentContext, type PostPaymentContext } from '@/lib/post-payment-context'
import { buildPaytoUri } from '@/lib/payto'
import { formatNpub, pubkeyToNpub } from '@/lib/pubkey'
import { useNostr } from '@/providers/NostrProvider'
import { useZap } from '@/providers/ZapProvider'
import lightning from '@/services/lightning.service'
import noteStatsService from '@/services/note-stats.service'
import type { RecipientPaymentData } from '@/hooks/useRecipientAlternativePayments'
import { NostrEvent } from 'nostr-tools'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { formatSatsGrouped } from '@/lib/lightning'
export function useNip57QuickZap(opts: {
recipientPubkey: string
referencedEvent?: NostrEvent
recipientPayment: RecipientPaymentData
onPostPaymentRequest?: (context: PostPaymentContext) => void
onZapDialogClose?: () => void
}) {
const { t } = useTranslation()
const { pubkey, checkLogin } = useNostr()
const { isWalletConnected, defaultZapSats, defaultZapComment } = useZap()
const [zapping, setZapping] = useState(false)
const lightningAddressOptions = useMemo(
() =>
buildOrderedZapLightningAddresses({
profileEvent: opts.recipientPayment.profileEvent,
profile: opts.recipientPayment.profile,
paymentInfo: opts.recipientPayment.paymentInfo
}),
[opts.recipientPayment]
)
const canQuickNip57Zap =
isWalletConnected &&
defaultZapSats >= 1 &&
lightningAddressOptions.length > 0 &&
!!pubkey &&
pubkey !== opts.recipientPubkey
const recipientNpubLabel = useMemo(() => {
const npub = pubkeyToNpub(opts.recipientPubkey)
return npub ? formatNpub(npub) : opts.recipientPubkey.slice(0, 12)
}, [opts.recipientPubkey])
const quickZapLabel = t('Zap this npub n sats', {
npub: recipientNpubLabel,
n: formatSatsGrouped(defaultZapSats)
})
const sendQuickZap = useCallback(() => {
if (!canQuickNip57Zap || zapping) return
checkLogin(async () => {
if (!pubkey) return
try {
setZapping(true)
const paymentDetails = {
amountMsat: defaultZapSats * 1000,
paytoUri: buildPaytoUri('lightning', lightningAddressOptions[0] ?? ''),
messageDraft: defaultZapComment.trim() || undefined
}
const zapResult = await lightning.zap(
pubkey,
opts.referencedEvent ?? opts.recipientPubkey,
defaultZapSats,
defaultZapComment,
opts.onZapDialogClose,
(result) => {
if (!result) return
opts.onPostPaymentRequest?.(
mergePostPaymentContext(
{
recipientPubkey: opts.recipientPubkey,
referencedEvent: opts.referencedEvent
},
paymentDetails
)
)
},
{
address: lightningAddressOptions[0],
candidates: lightningAddressOptions
}
)
if (!zapResult) return
if (opts.referencedEvent) {
noteStatsService.addZap(
pubkey,
opts.referencedEvent.id,
zapResult.invoice,
defaultZapSats,
defaultZapComment
)
}
} catch (error) {
toast.error(`${t('Zap failed')}: ${(error as Error).message}`)
} finally {
setZapping(false)
}
})
}, [
canQuickNip57Zap,
zapping,
checkLogin,
pubkey,
defaultZapSats,
defaultZapComment,
lightningAddressOptions,
opts,
t
])
return {
canQuickNip57Zap,
quickZapLabel,
sendQuickZap,
zapping
}
}

6
src/i18n/locales/en.ts

@ -152,6 +152,7 @@ export default { @@ -152,6 +152,7 @@ export default {
'Copy invoice': 'Copy invoice',
'Failed to create invoice': 'Failed to create invoice',
'Amount (sats)': 'Amount (sats)',
'Buy them a coffee.': 'Buy them a coffee.',
'Approximate equivalent:': 'Approximate equivalent:',
'Pay with connected wallet': 'Pay with connected wallet',
'Payment sent': 'Payment sent',
@ -662,10 +663,15 @@ export default { @@ -662,10 +663,15 @@ export default {
sats: 'sats',
'Zap to': 'Zap to',
'Zap n sats': 'Zap {{n}} sats',
'Zap this npub n sats': 'Zap {{npub}} {{n}} sats',
'Payment methods — long-press to zap': 'Payment methods — long-press to zap',
zapComment: 'Comment',
'Default zap amount': 'Default zap amount',
'Default zap comment': 'Default zap comment',
'Lightning Address (or LNURL)': 'Lightning Address (or LNURL)',
'Lightning address you will receive zaps at.': 'Lightning address you will receive zaps at.',
'Connected wallet address': 'Connected wallet address',
'This wallet did not report a Lightning address.': 'This wallet did not report a Lightning address.',
'Quick zap': 'Quick zap',
'Preferred payto category': 'Preferred payto category',
'Show this category expanded on payment method lists; other categories collapse behind an accordion.':

4
src/lib/lightning-zap-amount.test.ts

@ -20,10 +20,10 @@ function groupedDisplayString(amount: number): string { @@ -20,10 +20,10 @@ function groupedDisplayString(amount: number): string {
describe('lightning zap amounts', () => {
describe('LN invoice presets and defaults', () => {
it('defines min, default, and six preset buttons', () => {
it('defines min, default, and five preset buttons', () => {
expect(MIN_ZAP_SATS).toBe(210)
expect(DEFAULT_ZAP_SATS).toBe(420)
expect([...LN_INVOICE_PRESET_SATS]).toEqual([210, 420, 2100, 4200, 21_000, 42_000])
expect([...LN_INVOICE_PRESET_SATS]).toEqual([210, 420, 2100, 4200, 21_000])
expect(LN_INVOICE_PRESET_SATS[0]).toBe(MIN_ZAP_SATS)
expect(LN_INVOICE_PRESET_SATS).toContain(DEFAULT_ZAP_SATS)
})

5
src/lib/lightning.ts

@ -26,7 +26,10 @@ export const MIN_ZAP_SATS = 210 @@ -26,7 +26,10 @@ export const MIN_ZAP_SATS = 210
export const DEFAULT_ZAP_SATS = 420
/** Quick-pick amounts on the Lightning invoice page. */
export const LN_INVOICE_PRESET_SATS = [210, 420, 2100, 4200, 21_000, 42_000] as const
export const LN_INVOICE_PRESET_SATS = [210, 420, 2100, 4200, 21_000] as const
/** Invoice quick-pick with coffee tip affordance (wider preset button). */
export const LN_INVOICE_COFFEE_PRESET_SATS = 4200
/** Leading digit group + BTC hint styling above this amount (exclusive). */
export const ZAP_SATS_HIGHLIGHT_ABOVE = 999_999

35
src/lib/merge-payment-methods.ts

@ -1,11 +1,12 @@ @@ -1,11 +1,12 @@
import { getPaymentInfoFromEvent } from '@/lib/event-metadata'
import { getPaymentInfoFromEvent, getProfileFromEvent } from '@/lib/event-metadata'
import {
buildPaytoUri,
getCanonicalPaytoType,
getPaytoEditorTypeLabel,
getPaytoTypeInfo,
isKnownPaytoType,
isLightningPaytoType
isLightningPaytoType,
isZappableLightningPaytoType
} from '@/lib/payto'
import { extractKind0PaymentMethodsFromProfileJson } from '@/lib/payto-kind0-import'
import { normalizePaypalAuthority } from '@/lib/payto-paypal-url'
@ -567,3 +568,33 @@ export function groupPaymentMethodsForDisplay( @@ -567,3 +568,33 @@ export function groupPaymentMethodsForDisplay(
)
}
/** Lightning payto authorities from kind 0 / 10133 in merge order (NIP-57 candidates). */
export function buildOrderedZapLightningAddresses(opts: {
profileEvent?: Event | null
profile?: TProfile | null
paymentInfo: ReturnType<typeof getPaymentInfoFromEvent> | null
preferredAddress?: string | null
}): string[] {
const ev = opts.profileEvent
const profile =
ev?.kind === kinds.Metadata ? getProfileFromEvent(ev) : (opts.profile ?? null)
const addrs = mergePaymentMethods(opts.paymentInfo, profile, ev)
.filter((m) => isZappableLightningPaytoType(m.type))
.map((m) => m.authority)
return prioritizeZapLightningAddress(addrs, opts.preferredAddress ?? undefined)
}
/** Move `preferred` to the front when present; append if not already listed. */
export function prioritizeZapLightningAddress(candidates: string[], preferred?: string): string[] {
if (!preferred?.trim()) return candidates
const norm = normalizePaymentAuthority('lightning', preferred)
const idx = candidates.findIndex((c) => normalizePaymentAuthority('lightning', c) === norm)
if (idx === -1) {
return [resolveLightningAuthority(preferred.trim()), ...candidates]
}
const rest = candidates.filter((_, i) => i !== idx)
return [candidates[idx], ...rest]
}

45
src/lib/nip57-zap.ts

@ -0,0 +1,45 @@ @@ -0,0 +1,45 @@
import { ExtendedKind } from '@/constants'
import { getReplaceableCoordinateFromEvent } from '@/lib/event'
import { bech32 } from '@scure/base'
import { kinds, type Event, type NostrEvent } from 'nostr-tools'
import { utf8Encoder } from 'nostr-tools/utils'
export type ZapRequestTarget =
| { pubkey: string; event?: undefined }
| { pubkey: string; event: NostrEvent }
export function encodeLnurlBech32(url: string): string {
const trimmed = url.trim()
if (trimmed.toLowerCase().startsWith('lnurl')) return trimmed
const words = bech32.toWords(utf8Encoder.encode(trimmed))
return bech32.encode('lnurl', words, 1024)
}
/** Unsigned kind 9734 draft for signing via the active nostr signer. */
export function buildZapRequestDraft(opts: {
target: ZapRequestTarget
amountMsat: number
lnurlBech32: string
relays: string[]
comment?: string
}): Event {
const { target, amountMsat, lnurlBech32, relays, comment = '' } = opts
const tags: string[][] = [['relays', ...relays], ['amount', String(amountMsat)], ['lnurl', lnurlBech32], ['p', target.pubkey]]
if (target.event) {
if (kinds.isAddressableKind(target.event.kind)) {
tags.push(['a', getReplaceableCoordinateFromEvent(target.event)])
} else {
tags.push(['e', target.event.id])
}
tags.push(['k', String(target.event.kind)])
}
return {
kind: ExtendedKind.ZAP_REQUEST,
content: comment,
tags,
created_at: Math.floor(Date.now() / 1000),
pubkey: ''
} as Event
}

4
src/lib/post-payment-context.ts

@ -9,6 +9,8 @@ export type PostPaymentContext = { @@ -9,6 +9,8 @@ export type PostPaymentContext = {
amountMsat?: number
/** payto tag value without the `payto://` prefix. */
payto?: string
/** Pre-fill kind 9740 message (e.g. LNURL-pay invoice description). */
messageDraft?: string
/** Thread or wall reference for superchat placement. */
referencedEvent?: NostrEvent
}
@ -21,6 +23,7 @@ type BuildPostPaymentContextParams = { @@ -21,6 +23,7 @@ type BuildPostPaymentContextParams = {
paytoUri?: string
paytoType?: string
paytoAuthority?: string
messageDraft?: string
referencedEvent?: NostrEvent
}
@ -37,6 +40,7 @@ export function buildPostPaymentContext(params: BuildPostPaymentContextParams): @@ -37,6 +40,7 @@ export function buildPostPaymentContext(params: BuildPostPaymentContextParams):
recipientPubkey: params.recipientPubkey,
amountMsat: params.amountMsat,
payto,
messageDraft: params.messageDraft?.trim() || undefined,
referencedEvent: params.referencedEvent
}
}

21
src/lib/wallet-connection-details.ts

@ -4,22 +4,34 @@ export type TWalletConnectionDetails = { @@ -4,22 +4,34 @@ export type TWalletConnectionDetails = {
connectorName: string
connectorType: string
nwcRelayUrl: string | null
/** `lud16` query param on the NWC connection URI, when present. */
nwcLud16FromUrl: string | null
}
/** Parse `relay=` from a nostr+walletconnect:// connection string. */
export function parseNwcRelayUrl(nwcUrl: string): string | null {
function nwcUrlAsHttpUrl(nwcUrl: string): URL | null {
try {
const httpLike = nwcUrl
.replace(/^nostr\+walletconnect:\/\//i, 'http://')
.replace(/^nostrwalletconnect:\/\//i, 'http://')
.replace(/^nostr\+walletconnect:/i, 'http://')
.replace(/^nostrwalletconnect:/i, 'http://')
return new URL(httpLike).searchParams.get('relay')
return new URL(httpLike)
} catch {
return null
}
}
/** Parse `relay=` from a nostr+walletconnect:// connection string. */
export function parseNwcRelayUrl(nwcUrl: string): string | null {
return nwcUrlAsHttpUrl(nwcUrl)?.searchParams.get('relay') ?? null
}
/** Parse optional `lud16=` from a nostr+walletconnect:// connection string. */
export function parseNwcLud16FromUrl(nwcUrl: string): string | null {
const lud16 = nwcUrlAsHttpUrl(nwcUrl)?.searchParams.get('lud16')?.trim()
return lud16 || null
}
/** Read the active Bitcoin Connect wallet config (from localStorage-backed store). */
export function getBitcoinConnectWalletDetails(): TWalletConnectionDetails | null {
const config = getConnectorConfig()
@ -27,6 +39,7 @@ export function getBitcoinConnectWalletDetails(): TWalletConnectionDetails | nul @@ -27,6 +39,7 @@ export function getBitcoinConnectWalletDetails(): TWalletConnectionDetails | nul
return {
connectorName: config.connectorName,
connectorType: config.connectorType,
nwcRelayUrl: config.nwcUrl ? parseNwcRelayUrl(config.nwcUrl) : null
nwcRelayUrl: config.nwcUrl ? parseNwcRelayUrl(config.nwcUrl) : null,
nwcLud16FromUrl: config.nwcUrl ? parseNwcLud16FromUrl(config.nwcUrl) : null
}
}

32
src/lib/webln-payment.ts

@ -11,12 +11,40 @@ function delay(ms: number): Promise<void> { @@ -11,12 +11,40 @@ function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
type NwcClientLike = {
getInfo?: () => Promise<{ lud16?: string; lud06?: string }>
}
/** NIP-47 `get_info` may expose the wallet’s lud16; WebLN `getInfo()` often omits it. */
export async function resolveWalletLightningAddress(
provider: WebLNProvider,
info?: GetInfoResponse | null
): Promise<string | null> {
const extended = (info ?? null) as (GetInfoResponse & { lud16?: string; lud06?: string }) | null
if (extended?.lud16?.trim()) return extended.lud16.trim()
if (extended?.lud06?.trim()) return extended.lud06.trim()
const client = (provider as WebLNProvider & { client?: NwcClientLike }).client
if (!client?.getInfo) return null
try {
const nip47 = await client.getInfo()
if (nip47.lud16?.trim()) return nip47.lud16.trim()
if (nip47.lud06?.trim()) return nip47.lud06.trim()
} catch {
/* wallet did not report a receive address */
}
return null
}
/** Enable WebLN and load wallet info so NWC encryption is negotiated before paying. */
export async function prepareConnectedWebLNProvider(
provider: WebLNProvider
): Promise<GetInfoResponse> {
): Promise<{ info: GetInfoResponse; walletLightningAddress: string | null }> {
await provider.enable()
return provider.getInfo()
const info = await provider.getInfo()
const walletLightningAddress = await resolveWalletLightningAddress(provider, info)
return { info, walletLightningAddress }
}
export async function sendWebLNPaymentWithRetry(

25
src/pages/secondary/WalletPage/IncludePublicZapReceiptSwitch.tsx

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { useZap } from '@/providers/ZapProvider'
import { useTranslation } from 'react-i18next'
export default function IncludePublicZapReceiptSwitch() {
const { t } = useTranslation()
const { includePublicZapReceipt, updateIncludePublicZapReceipt } = useZap()
return (
<div className="flex w-full items-center justify-between gap-3">
<Label htmlFor="include-public-zap-receipt-switch" className="flex-1">
<div className="text-base font-medium">{t('Include public zap receipt')}</div>
<div className="text-sm font-normal text-muted-foreground">
{t('When off, your zap may still succeed but a public receipt may not be published to relays')}
</div>
</Label>
<Switch
id="include-public-zap-receipt-switch"
checked={includePublicZapReceipt}
onCheckedChange={updateIncludePublicZapReceipt}
/>
</div>
)
}

2
src/pages/secondary/WalletPage/LightningAddressInput.tsx

@ -52,7 +52,7 @@ export default function LightningAddressInput() { @@ -52,7 +52,7 @@ export default function LightningAddressInput() {
return (
<div className="w-full space-y-1">
<Label htmlFor="ln-address">{t('Lightning Address (or LNURL)')}</Label>
<Label htmlFor="ln-address">{t('Lightning address you will receive zaps at.')}</Label>
<div className="flex w-full items-center gap-2">
<Input
id="ln-address"

37
src/pages/secondary/WalletPage/WalletConnectionDetails.tsx

@ -7,19 +7,28 @@ import { toast } from 'sonner' @@ -7,19 +7,28 @@ import { toast } from 'sonner'
export default function WalletConnectionDetails() {
const { t } = useTranslation()
const { isWalletConnected } = useZap()
const { isWalletConnected, walletLightningAddress } = useZap()
if (!isWalletConnected) return null
const details = getBitcoinConnectWalletDetails()
if (!details) return null
const connectedWalletAddress =
walletLightningAddress ?? details.nwcLud16FromUrl ?? null
const copyRelay = () => {
if (!details.nwcRelayUrl) return
navigator.clipboard.writeText(details.nwcRelayUrl)
toast.success(t('Copied to clipboard'))
}
const copyAddress = () => {
if (!connectedWalletAddress) return
navigator.clipboard.writeText(connectedWalletAddress)
toast.success(t('Copied to clipboard'))
}
return (
<div className="mb-3 space-y-2 rounded-lg border border-border/80 bg-muted/25 px-3 py-2.5 text-sm">
<div>
@ -31,6 +40,32 @@ export default function WalletConnectionDetails() { @@ -31,6 +40,32 @@ export default function WalletConnectionDetails() {
<span className="text-muted-foreground"> ({details.connectorType})</span>
</p>
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t('Connected wallet address')}
</p>
{connectedWalletAddress ? (
<div className="mt-1 flex min-w-0 items-start gap-2">
<p className="min-w-0 flex-1 break-all font-mono text-xs leading-relaxed text-foreground/90">
{connectedWalletAddress}
</p>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0"
onClick={copyAddress}
aria-label={t('Copy to clipboard')}
>
<Copy className="size-4" />
</Button>
</div>
) : (
<p className="mt-0.5 text-muted-foreground">
{t('This wallet did not report a Lightning address.')}
</p>
)}
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t('NWC relay')}

4
src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx

@ -14,6 +14,8 @@ import { useZap } from '@/providers/ZapProvider' @@ -14,6 +14,8 @@ import { useZap } from '@/providers/ZapProvider'
import { disconnect, launchModal } from '@getalby/bitcoin-connect-react'
import { useTranslation } from 'react-i18next'
import DefaultZapAmountInput from './DefaultZapAmountInput'
import DefaultZapCommentInput from './DefaultZapCommentInput'
import IncludePublicZapReceiptSwitch from './IncludePublicZapReceiptSwitch'
import WalletConnectionDetails from './WalletConnectionDetails'
export default function WalletZapSendingSettings() {
@ -51,6 +53,8 @@ export default function WalletZapSendingSettings() { @@ -51,6 +53,8 @@ export default function WalletZapSendingSettings() {
</AlertDialog>
</div>
<DefaultZapAmountInput />
<DefaultZapCommentInput />
<IncludePublicZapReceiptSwitch />
</>
)
}

10
src/providers/ZapProvider.tsx

@ -12,6 +12,8 @@ type TZapContext = { @@ -12,6 +12,8 @@ type TZapContext = {
isWalletConnected: boolean
provider: WebLNProvider | null
walletInfo: GetInfoResponse | null
/** lud16/lud06 reported by the connected NWC wallet, when available. */
walletLightningAddress: string | null
defaultZapSats: number
updateDefaultSats: (sats: number) => void
defaultZapComment: string
@ -43,6 +45,7 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { @@ -43,6 +45,7 @@ export function ZapProvider({ children }: { children: React.ReactNode }) {
const [isWalletConnected, setIsWalletConnected] = useState(false)
const [provider, setProvider] = useState<WebLNProvider | null>(null)
const [walletInfo, setWalletInfo] = useState<GetInfoResponse | null>(null)
const [walletLightningAddress, setWalletLightningAddress] = useState<string | null>(null)
useEffect(() => {
if (!LIGHTNING_WALLET_PAY_ENABLED) return
@ -50,11 +53,13 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { @@ -50,11 +53,13 @@ export function ZapProvider({ children }: { children: React.ReactNode }) {
const unSubOnConnected = onConnected((provider) => {
setIsWalletConnected(false)
setWalletInfo(null)
setWalletLightningAddress(null)
void prepareConnectedWebLNProvider(provider)
.then((info) => {
.then(({ info, walletLightningAddress }) => {
setProvider(provider)
lightningService.provider = provider
setWalletInfo(info)
setWalletLightningAddress(walletLightningAddress)
setIsWalletConnected(true)
})
.catch((error) => {
@ -68,6 +73,8 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { @@ -68,6 +73,8 @@ export function ZapProvider({ children }: { children: React.ReactNode }) {
const unSubOnDisconnected = onDisconnected(() => {
setIsWalletConnected(false)
setProvider(null)
setWalletInfo(null)
setWalletLightningAddress(null)
lightningService.provider = null
})
@ -103,6 +110,7 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { @@ -103,6 +110,7 @@ export function ZapProvider({ children }: { children: React.ReactNode }) {
isWalletConnected,
provider,
walletInfo,
walletLightningAddress,
defaultZapSats,
updateDefaultSats,
defaultZapComment,

209
src/services/lightning.service.ts

@ -1,26 +1,33 @@ @@ -1,26 +1,33 @@
import {
CODY_PUBKEY,
FAST_READ_RELAY_URLS,
FAST_WRITE_RELAY_URLS,
IMWALD_MAINTAINER_PUBKEY
} from '@/constants'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { getProfileFromEvent, getZapInfoFromEvent } from '@/lib/event-metadata'
import { closeModal, init, launchPaymentModal } from '@getalby/bitcoin-connect-react'
import {
isNwcWalletServiceInfoError,
sendWebLNPaymentWithRetry
} from '@/lib/webln-payment'
import { Invoice } from '@getalby/lightning-tools'
import { bech32 } from '@scure/base'
import { WebLNProvider } from '@webbtc/webln-types'
import dayjs from 'dayjs'
import { kinds, NostrEvent } from 'nostr-tools'
import { Filter, kinds, NostrEvent } from 'nostr-tools'
import { SubCloser } from 'nostr-tools/abstract-pool'
import { utf8Decoder } from 'nostr-tools/utils'
import { queryService } from './client.service'
import client, { queryService, replaceableEventService } from './client.service'
import { clampZapSats } from '@/lib/lightning'
import { fetchWithTimeout } from '@/lib/fetch-with-timeout'
import { buildLnurlPayCallbackUrl, parseLnurlCommentAllowed } from '@/lib/lnurl-pay'
import { buildZapRequestDraft, encodeLnurlBech32 } from '@/lib/nip57-zap'
import { prioritizeZapLightningAddress } from '@/lib/merge-payment-methods'
import logger from '@/lib/logger'
import { runAfterReleasingRadixScrollLock } from '@/lib/react-remove-scroll-body-cleanup'
import storage from '@/services/local-storage.service'
import { TProfile } from '@/types'
export type TRecentSupporter = { pubkey: string; amount: number; comment?: string }
@ -65,14 +72,143 @@ class LightningService { @@ -65,14 +72,143 @@ class LightningService {
onPaymentFlowComplete?: (result: PaymentFlowResult) => void,
zapLightning?: { address?: string; candidates?: string[] }
): Promise<PaymentFlowResult> {
void sender
void recipientOrEvent
void sats
void comment
void closeOuterModel
void onPaymentFlowComplete
void zapLightning
throw new Error('NIP-57 zaps are not supported; use payment targets or LNURL-pay invoices')
if (!client.signer) {
throw new Error('You need to be logged in to zap')
}
const clampedSats = clampZapSats(sats)
if (clampedSats < 1) {
throw new Error('Amount must be at least 1 sat')
}
const { recipient, event } =
typeof recipientOrEvent === 'string'
? { recipient: recipientOrEvent }
: { recipient: recipientOrEvent.pubkey, event: recipientOrEvent }
const [profile, senderRelayList] = await Promise.all([
(async () => {
const profileEvent = await replaceableEventService.fetchReplaceableEvent(recipient, kinds.Metadata)
return profileEvent ? getProfileFromEvent(profileEvent) : undefined
})(),
sender
? client.fetchRelayList(sender)
: Promise.resolve({ read: FAST_READ_RELAY_URLS, write: FAST_WRITE_RELAY_URLS })
])
if (!profile) {
throw new Error('Recipient not found')
}
const zapEndpoint = await this.getZapEndpoint(profile, zapLightning)
if (!zapEndpoint) {
throw new Error("Recipient's lightning address does not support NIP-57 zaps")
}
const { callback, lnurlBech32 } = zapEndpoint
const amount = clampedSats * 1000
const relays = storage.getIncludePublicZapReceipt()
? senderRelayList.write.concat(FAST_READ_RELAY_URLS).slice(0, 8)
: []
const zapRequestDraft = buildZapRequestDraft({
target: event ? { pubkey: recipient, event } : { pubkey: recipient },
amountMsat: amount,
lnurlBech32,
relays,
comment
})
const zapRequest = await client.signer.signEvent(zapRequestDraft)
const zapRequestUrl = buildLnurlPayCallbackUrl(callback, {
amount: String(amount),
nostr: JSON.stringify(zapRequest),
lnurl: lnurlBech32
})
const zapRequestRes = await fetchWithTimeout(zapRequestUrl, { timeoutMs: 25_000 })
const zapRequestResBody = (await zapRequestRes.json()) as {
pr?: string
verify?: string
reason?: string
error?: string
message?: string
}
if (zapRequestResBody.error) {
throw new Error(zapRequestResBody.message ?? String(zapRequestResBody.error))
}
const { pr, verify, reason } = zapRequestResBody
if (!pr) {
throw new Error(reason ?? 'Failed to create invoice')
}
if (this.provider) {
try {
const { preimage } = await sendWebLNPaymentWithRetry(this.provider, pr)
closeOuterModel?.()
const result = { preimage, invoice: pr }
onPaymentFlowComplete?.(result)
return result
} catch (error) {
if (!isNwcWalletServiceInfoError(error)) {
throw error
}
}
}
return new Promise((resolve) => {
runAfterReleasingRadixScrollLock(closeOuterModel, () => {
closeModal()
let checkPaymentInterval: ReturnType<typeof setInterval> | undefined
let subCloser: SubCloser | undefined
const finish = (result: PaymentFlowResult) => {
clearInterval(checkPaymentInterval)
subCloser?.close()
onPaymentFlowComplete?.(result)
resolve(result)
}
const { setPaid } = launchPaymentModal({
invoice: pr,
onPaid: (response) => {
finish({ preimage: response.preimage, invoice: pr })
},
onCancelled: () => {
finish(null)
}
})
if (verify) {
checkPaymentInterval = setInterval(async () => {
const invoice = new Invoice({ pr, verify })
const paid = await invoice.verifyPayment()
if (paid && invoice.preimage) {
setPaid({
preimage: invoice.preimage
})
}
}, 1000)
} else {
const filter: Filter = {
kinds: [kinds.Zap],
'#p': [recipient],
since: dayjs().subtract(1, 'minute').unix()
}
if (event) {
filter['#e'] = [event.id]
}
subCloser = client.subscribe(
senderRelayList.write.concat(FAST_READ_RELAY_URLS).slice(0, 4),
filter,
{
onevent: (evt) => {
const info = getZapInfoFromEvent(evt)
if (!info) return
if (info.invoice === pr) {
setPaid({ preimage: info.preimage ?? '' })
}
}
}
)
}
})
})
}
async payInvoice(
@ -209,6 +345,57 @@ class LightningService { @@ -209,6 +345,57 @@ class LightningService {
return body.pr
}
private async getZapEndpoint(
profile: TProfile,
zapLightning?: { address?: string; candidates?: string[] }
): Promise<null | {
callback: string
lnurlBech32: string
}> {
const candidates = zapLightning?.candidates?.length
? prioritizeZapLightningAddress(zapLightning.candidates, zapLightning.address)
: this.lightningAddressCandidates(profile, zapLightning?.address)
for (const addr of candidates) {
const resolved = await this.fetchLnurlPayZapEndpoint(addr)
if (resolved) return resolved
}
return null
}
/** Ordered lightning identifiers from kind 0 (lud16/lud06 + `w` lightning rows); de-duplicated. */
private lightningAddressCandidates(profile: TProfile, preferredFirst?: string): string[] {
const raw =
profile.lightningAddressList?.length && profile.lightningAddressList.length > 0
? profile.lightningAddressList
: profile.lightningAddress
? [profile.lightningAddress]
: []
const out: string[] = []
const seen = new Set<string>()
for (const a of raw) {
const t = a?.trim()
if (!t) continue
const k = t.toLowerCase()
if (seen.has(k)) continue
seen.add(k)
out.push(t)
}
return prioritizeZapLightningAddress(out, preferredFirst)
}
private async fetchLnurlPayZapEndpoint(lightningAddress: string): Promise<null | {
callback: string
lnurlBech32: string
}> {
const meta = await this.resolveLnurlPayMetadata(lightningAddress)
if (!meta?.allowsNostr || !meta.nostrPubkey) return null
const trimmed = lightningAddress.trim()
const lnurlBech32 = trimmed.toLowerCase().startsWith('lnurl')
? trimmed
: encodeLnurlBech32(meta.lnurl)
return { callback: meta.callback, lnurlBech32 }
}
private async resolveLnurlPayMetadata(lightningAddress: string): Promise<null | {
callback: string
lnurl: string

Loading…
Cancel
Save