diff --git a/src/components/ZapDialog/Nip57QuickZapButton.tsx b/src/components/ZapDialog/Nip57QuickZapButton.tsx
new file mode 100644
index 00000000..1fe15030
--- /dev/null
+++ b/src/components/ZapDialog/Nip57QuickZapButton.tsx
@@ -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 (
+
+ )
+}
diff --git a/src/components/ZapDialog/PostPaymentMessagePrompt.tsx b/src/components/ZapDialog/PostPaymentMessagePrompt.tsx
index e5615ba8..21eb38e8 100644
--- a/src/components/ZapDialog/PostPaymentMessagePrompt.tsx
+++ b/src/components/ZapDialog/PostPaymentMessagePrompt.tsx
@@ -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({
useEffect(() => {
if (open) {
- setStep('choice')
+ setStep(postPaymentInitialStep(paymentContext))
}
- }, [open, recipientPubkey])
+ }, [open, recipientPubkey, paymentContext?.amountMsat, paymentContext?.payto, paymentContext?.messageDraft])
useEffect(() => {
if (!open || step !== 'choice') return
diff --git a/src/components/ZapDialog/SuperchatRequestForm.tsx b/src/components/ZapDialog/SuperchatRequestForm.tsx
index 335522af..b6e89445 100644
--- a/src/components/ZapDialog/SuperchatRequestForm.tsx
+++ b/src/components/ZapDialog/SuperchatRequestForm.tsx
@@ -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
(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(() => {
diff --git a/src/components/ZapDialog/index.tsx b/src/components/ZapDialog/index.tsx
index fa0c9893..9d076a67 100644
--- a/src/components/ZapDialog/index.tsx
+++ b/src/components/ZapDialog/index.tsx
@@ -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'
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({
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 ? (
-
+ paymentGroups.length > 0 || canQuickNip57Zap ? (
+ <>
+ {canQuickNip57Zap ? (
+
+ ) : null}
+ {paymentGroups.length > 0 ? (
+
+ ) : null}
+ >
) : (
{t('No payment methods available for this profile')}
diff --git a/src/constants.ts b/src/constants.ts
index b1f61d83..26d4c098 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -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. */
diff --git a/src/hooks/useNip57QuickZap.ts b/src/hooks/useNip57QuickZap.ts
new file mode 100644
index 00000000..52b14d79
--- /dev/null
+++ b/src/hooks/useNip57QuickZap.ts
@@ -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
+ }
+}
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 6f1e836a..9262cac0 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -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 {
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.':
diff --git a/src/lib/lightning-zap-amount.test.ts b/src/lib/lightning-zap-amount.test.ts
index a47ed183..630f1618 100644
--- a/src/lib/lightning-zap-amount.test.ts
+++ b/src/lib/lightning-zap-amount.test.ts
@@ -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)
})
diff --git a/src/lib/lightning.ts b/src/lib/lightning.ts
index f6fae39a..8f66072a 100644
--- a/src/lib/lightning.ts
+++ b/src/lib/lightning.ts
@@ -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
diff --git a/src/lib/merge-payment-methods.ts b/src/lib/merge-payment-methods.ts
index b38e1af7..55a9418a 100644
--- a/src/lib/merge-payment-methods.ts
+++ b/src/lib/merge-payment-methods.ts
@@ -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(
)
}
+/** 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 | 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]
+}
+
diff --git a/src/lib/nip57-zap.ts b/src/lib/nip57-zap.ts
new file mode 100644
index 00000000..39f3cd50
--- /dev/null
+++ b/src/lib/nip57-zap.ts
@@ -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
+}
diff --git a/src/lib/post-payment-context.ts b/src/lib/post-payment-context.ts
index c8fa9abb..a8e819ab 100644
--- a/src/lib/post-payment-context.ts
+++ b/src/lib/post-payment-context.ts
@@ -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 = {
paytoUri?: string
paytoType?: string
paytoAuthority?: string
+ messageDraft?: string
referencedEvent?: NostrEvent
}
@@ -37,6 +40,7 @@ export function buildPostPaymentContext(params: BuildPostPaymentContextParams):
recipientPubkey: params.recipientPubkey,
amountMsat: params.amountMsat,
payto,
+ messageDraft: params.messageDraft?.trim() || undefined,
referencedEvent: params.referencedEvent
}
}
diff --git a/src/lib/wallet-connection-details.ts b/src/lib/wallet-connection-details.ts
index 1129685b..3f0d9637 100644
--- a/src/lib/wallet-connection-details.ts
+++ b/src/lib/wallet-connection-details.ts
@@ -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
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
}
}
diff --git a/src/lib/webln-payment.ts b/src/lib/webln-payment.ts
index 21d30456..c92893e7 100644
--- a/src/lib/webln-payment.ts
+++ b/src/lib/webln-payment.ts
@@ -11,12 +11,40 @@ function delay(ms: number): Promise {
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 {
+ 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 {
+): 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(
diff --git a/src/pages/secondary/WalletPage/IncludePublicZapReceiptSwitch.tsx b/src/pages/secondary/WalletPage/IncludePublicZapReceiptSwitch.tsx
new file mode 100644
index 00000000..f39b3f23
--- /dev/null
+++ b/src/pages/secondary/WalletPage/IncludePublicZapReceiptSwitch.tsx
@@ -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 (
+
+
+
+
+ )
+}
diff --git a/src/pages/secondary/WalletPage/LightningAddressInput.tsx b/src/pages/secondary/WalletPage/LightningAddressInput.tsx
index fb6deece..fd7ad4c6 100644
--- a/src/pages/secondary/WalletPage/LightningAddressInput.tsx
+++ b/src/pages/secondary/WalletPage/LightningAddressInput.tsx
@@ -52,7 +52,7 @@ export default function LightningAddressInput() {
return (
-
+
{
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 (
@@ -31,6 +40,32 @@ export default function WalletConnectionDetails() {
({details.connectorType})
+
+
+ {t('Connected wallet address')}
+
+ {connectedWalletAddress ? (
+
+
+ {connectedWalletAddress}
+
+
+
+ ) : (
+
+ {t('This wallet did not report a Lightning address.')}
+
+ )}
+
{t('NWC relay')}
diff --git a/src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx b/src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx
index b7bd05bd..d0c372ce 100644
--- a/src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx
+++ b/src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx
@@ -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() {
+
+
>
)
}
diff --git a/src/providers/ZapProvider.tsx b/src/providers/ZapProvider.tsx
index d54360b1..360b48f3 100644
--- a/src/providers/ZapProvider.tsx
+++ b/src/providers/ZapProvider.tsx
@@ -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 }) {
const [isWalletConnected, setIsWalletConnected] = useState(false)
const [provider, setProvider] = useState
(null)
const [walletInfo, setWalletInfo] = useState(null)
+ const [walletLightningAddress, setWalletLightningAddress] = useState(null)
useEffect(() => {
if (!LIGHTNING_WALLET_PAY_ENABLED) return
@@ -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 }) {
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 }) {
isWalletConnected,
provider,
walletInfo,
+ walletLightningAddress,
defaultZapSats,
updateDefaultSats,
defaultZapComment,
diff --git a/src/services/lightning.service.ts b/src/services/lightning.service.ts
index 0eb97f85..a5796d12 100644
--- a/src/services/lightning.service.ts
+++ b/src/services/lightning.service.ts
@@ -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 {
onPaymentFlowComplete?: (result: PaymentFlowResult) => void,
zapLightning?: { address?: string; candidates?: string[] }
): Promise {
- 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 | 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 {
return body.pr
}
+ private async getZapEndpoint(
+ profile: TProfile,
+ zapLightning?: { address?: string; candidates?: string[] }
+ ): Promise {
+ 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()
+ 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 {
+ 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