Browse Source

Add LN invoices

imwald
Silberengel 4 weeks ago
parent
commit
51f18153fc
  1. 7
      src/components/Embedded/EmbeddedLNInvoice.tsx
  2. 332
      src/components/PaytoDialog/LightningInvoiceSection.tsx
  3. 101
      src/components/PaytoDialog/index.tsx
  4. 5
      src/constants.ts
  5. 17
      src/i18n/locales/en.ts
  6. 4
      src/pages/secondary/WalletPage/index.tsx
  7. 4
      src/providers/ZapProvider.tsx
  8. 117
      src/services/lightning.service.ts

7
src/components/Embedded/EmbeddedLNInvoice.tsx

@ -2,7 +2,6 @@ import { Button } from '@/components/ui/button' @@ -2,7 +2,6 @@ import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { formatAmount, getAmountFromInvoice } from '@/lib/lightning'
import { cn } from '@/lib/utils'
import { useNostr } from '@/providers/NostrProvider'
import lightning from '@/services/lightning.service'
import { Zap } from 'lucide-react'
import { useMemo, useState } from 'react'
@ -11,7 +10,6 @@ import { toast } from 'sonner' @@ -11,7 +10,6 @@ import { toast } from 'sonner'
export function EmbeddedLNInvoice({ invoice, className }: { invoice: string; className?: string }) {
const { t } = useTranslation()
const { checkLogin, pubkey } = useNostr()
const [paying, setPaying] = useState(false)
const amount = useMemo(() => {
@ -20,9 +18,6 @@ export function EmbeddedLNInvoice({ invoice, className }: { invoice: string; cla @@ -20,9 +18,6 @@ export function EmbeddedLNInvoice({ invoice, className }: { invoice: string; cla
const handlePay = async () => {
try {
if (!pubkey) {
throw new Error('You need to be logged in to zap')
}
setPaying(true)
const invoiceResult = await lightning.payInvoice(invoice)
// user canceled
@ -38,7 +33,7 @@ export function EmbeddedLNInvoice({ invoice, className }: { invoice: string; cla @@ -38,7 +33,7 @@ export function EmbeddedLNInvoice({ invoice, className }: { invoice: string; cla
const handlePayClick = (e: React.MouseEvent) => {
e.stopPropagation()
checkLogin(() => handlePay())
void handlePay()
}
return (

332
src/components/PaytoDialog/LightningInvoiceSection.tsx

@ -0,0 +1,332 @@ @@ -0,0 +1,332 @@
import QrCode from '@/components/QrCode'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Textarea } from '@/components/ui/textarea'
import { LIGHTNING_WALLET_PAY_ENABLED } from '@/constants'
import {
clampZapSats,
formatAmount,
formatSatsGrouped,
getAmountFromInvoice,
parseGroupedIntegerInput
} from '@/lib/lightning'
import { cn } from '@/lib/utils'
import { useZap } from '@/providers/ZapProvider'
import lightning from '@/services/lightning.service'
import { Check, Copy, Wallet, Zap } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
const PRESET_SATS = [21, 210, 420, 1000, 2100, 10_000, 21_000]
function invoiceQrPayload(pr: string): string {
const trimmed = pr.trim()
if (trimmed.toLowerCase().startsWith('lightning:')) return trimmed
return `lightning:${trimmed}`
}
export default function LightningInvoiceSection({
lightningAddress,
paytoUri
}: {
lightningAddress: string
paytoUri: string
}) {
const { t } = useTranslation()
const { defaultZapSats, defaultZapComment, isWalletConnected } = useZap()
const [sats, setSats] = useState(() => clampZapSats(defaultZapSats))
const [description, setDescription] = useState(defaultZapComment)
const [commentMax, setCommentMax] = useState<number | null>(null)
const [invoice, setInvoice] = useState<string | null>(null)
const [invoiceDescription, setInvoiceDescription] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [paying, setPaying] = useState(false)
useEffect(() => {
setSats(clampZapSats(defaultZapSats))
setDescription(defaultZapComment)
setInvoice(null)
setInvoiceDescription(null)
setCommentMax(null)
let cancelled = false
void lightning.getLnurlPayInvoiceOptions(lightningAddress).then((opts) => {
if (!cancelled) setCommentMax(opts?.commentAllowed ?? 0)
})
return () => {
cancelled = true
}
}, [lightningAddress, defaultZapSats, defaultZapComment])
useEffect(() => {
setInvoice(null)
setInvoiceDescription(null)
}, [sats, description])
const invoiceSats = useMemo(() => {
if (!invoice) return null
try {
return getAmountFromInvoice(invoice)
} catch {
return null
}
}, [invoice])
const invoiceQrValue = useMemo(
() => (invoice ? invoiceQrPayload(invoice) : ''),
[invoice]
)
const handleCreateInvoice = async () => {
try {
setCreating(true)
const trimmedDesc = description.trim()
const pr = await lightning.createLnurlInvoice(lightningAddress, sats, {
description: trimmedDesc || undefined
})
setInvoice(pr)
setInvoiceDescription(trimmedDesc || null)
} catch (error) {
toast.error(`${t('Failed to create invoice')}: ${(error as Error).message}`)
} finally {
setCreating(false)
}
}
const handlePay = async () => {
if (!invoice) return
try {
setPaying(true)
const result = await lightning.payInvoice(invoice)
if (result) {
toast.success(t('Payment sent'))
setInvoice(null)
setInvoiceDescription(null)
}
} catch (error) {
toast.error(`${t('Lightning payment failed')}: ${(error as Error).message}`)
} finally {
setPaying(false)
}
}
const copyText = (text: string, message?: string) => {
navigator.clipboard.writeText(text)
toast.success(message ?? t('Copied to clipboard'))
}
return (
<section
className="min-w-0 space-y-5 rounded-xl border border-border/80 bg-muted/25 p-4 sm:p-5 text-base"
aria-label={t('Lightning payment')}
>
<div className="min-w-0 rounded-lg bg-background/90 px-3 py-2.5 ring-1 ring-border/60">
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t('Pay to')}
</p>
<p className="break-all text-base font-medium leading-snug sm:text-lg">{lightningAddress}</p>
</div>
<div className="min-w-0 space-y-3">
<Label htmlFor="ln-invoice-sats" className="text-sm font-medium text-muted-foreground sm:text-base">
{t('Amount (sats)')}
</Label>
<div className="flex min-w-0 items-center gap-3">
<Input
id="ln-invoice-sats"
inputMode="numeric"
value={sats === 0 ? '' : formatSatsGrouped(sats)}
onChange={(e) => setSats(parseGroupedIntegerInput(e.target.value))}
className="h-12 min-w-0 flex-1 text-xl font-semibold tabular-nums sm:h-14 sm:text-2xl"
aria-describedby="ln-invoice-preset-hint"
/>
<span className="shrink-0 text-base font-medium text-muted-foreground sm:text-lg">{t('sats')}</span>
</div>
<div
id="ln-invoice-preset-hint"
className="grid min-w-0 grid-cols-3 gap-1.5 sm:grid-cols-6"
role="group"
aria-label={t('Amount (sats)')}
>
{PRESET_SATS.map((preset) => {
const active = sats === preset
return (
<Button
key={preset}
type="button"
variant={active ? 'default' : 'outline'}
size="default"
className={cn(
'h-10 min-w-0 px-1.5 text-sm tabular-nums sm:text-base',
active && 'ring-1 ring-yellow-400/50'
)}
onClick={() => setSats(preset)}
>
{formatAmount(preset)}
</Button>
)
})}
</div>
</div>
{commentMax === null ? (
<Skeleton className="h-[4.5rem] w-full rounded-lg" aria-hidden />
) : commentMax > 0 ? (
<div className="min-w-0 space-y-2">
<div className="flex items-baseline justify-between gap-2">
<Label htmlFor="ln-invoice-description" className="text-sm font-medium text-muted-foreground sm:text-base">
{t('Description (optional)')}
</Label>
<span className="shrink-0 text-sm tabular-nums text-muted-foreground">
{description.length}/{commentMax}
</span>
</div>
<Textarea
id="ln-invoice-description"
value={description}
onChange={(e) => setDescription(e.target.value.slice(0, commentMax))}
maxLength={commentMax}
rows={3}
placeholder={t('Payment description')}
className="min-h-[5rem] resize-none text-base leading-relaxed sm:text-lg"
/>
</div>
) : (
<p className="rounded-lg bg-muted/50 px-3 py-2.5 text-sm leading-relaxed text-muted-foreground sm:text-base">
{t('This address does not support payment descriptions.')}
</p>
)}
{!invoice ? (
<Button
type="button"
className="h-12 w-full gap-2 text-base sm:h-14 sm:text-lg"
disabled={creating || sats < 1}
onClick={() => void handleCreateInvoice()}
>
{creating ? (
<Skeleton className="size-5 shrink-0 rounded-full" aria-hidden />
) : (
<Zap className="size-5 shrink-0 text-yellow-400" />
)}
{t('Create invoice')}
</Button>
) : null}
{invoice ? (
<div
className="min-w-0 space-y-3 rounded-lg border border-green-500/35 bg-green-500/[0.07] p-3 sm:p-4"
role="status"
aria-live="polite"
>
<div className="flex min-w-0 items-start gap-2">
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-green-500/20">
<Check className="size-5 text-green-600 dark:text-green-400" aria-hidden />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium uppercase tracking-wide text-green-700 dark:text-green-400">
{t('Invoice ready')}
</p>
{invoiceSats != null ? (
<p className="mt-1 text-2xl font-bold tabular-nums tracking-tight sm:text-3xl">
{formatSatsGrouped(invoiceSats)}{' '}
<span className="text-base font-semibold text-muted-foreground sm:text-lg">{t('sats')}</span>
</p>
) : null}
{invoiceDescription ? (
<p className="mt-2 text-base leading-snug text-foreground/90 break-words sm:text-lg">
{invoiceDescription}
</p>
) : null}
</div>
</div>
<div
className="flex min-w-0 flex-col items-center gap-2"
role="img"
aria-label={t('Scan to pay with a Lightning wallet')}
>
<QrCode value={invoiceQrValue} size={240} />
<p className="text-center text-sm text-muted-foreground sm:text-base">
{t('Scan to pay with a Lightning wallet')}
</p>
</div>
<div className="min-w-0">
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t('BOLT11 invoice')}
</p>
<div className="max-h-32 min-w-0 overflow-y-auto overflow-x-hidden rounded-md bg-background/80 p-3 ring-1 ring-border/50">
<p className="select-text break-all font-mono text-sm leading-relaxed text-muted-foreground sm:text-base">
{invoice}
</p>
</div>
</div>
<div className="grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-2">
<Button
type="button"
variant="secondary"
className="h-11 w-full min-w-0 gap-2 text-base"
onClick={() => copyText(invoice)}
>
<Copy className="size-5 shrink-0" />
<span className="truncate">{t('Copy invoice')}</span>
</Button>
{LIGHTNING_WALLET_PAY_ENABLED ? (
<Button
type="button"
variant="default"
className="h-11 w-full min-w-0 gap-2 text-base"
disabled={paying}
onClick={() => void handlePay()}
>
{paying ? (
<Skeleton className="size-5 shrink-0 rounded-full" aria-hidden />
) : (
<Wallet className="size-5 shrink-0" />
)}
<span className="truncate">
{isWalletConnected ? t('Pay with connected wallet') : t('Open in wallet')}
</span>
</Button>
) : null}
</div>
<Button
type="button"
variant="ghost"
className="h-10 w-full text-base text-muted-foreground"
onClick={() => void handleCreateInvoice()}
disabled={creating}
>
{t('Regenerate invoice')}
</Button>
</div>
) : null}
<div className="grid min-w-0 grid-cols-1 gap-2 border-t border-border/60 pt-3 sm:grid-cols-2">
<Button
type="button"
variant="outline"
className="h-11 min-w-0 flex-1 gap-2 text-base"
onClick={() => copyText(lightningAddress, t('Copied {{label}} address', { label: 'Lightning' }))}
>
<Copy className="size-5 shrink-0" />
<span className="truncate">{t('Copy address')}</span>
</Button>
<Button
type="button"
variant="outline"
className="h-11 min-w-0 flex-1 gap-2 text-base"
onClick={() => copyText(paytoUri)}
>
<Copy className="size-5 shrink-0" />
<span className="truncate">{t('Copy payto URI')}</span>
</Button>
</div>
</section>
)
}

101
src/components/PaytoDialog/index.tsx

@ -14,6 +14,8 @@ import { @@ -14,6 +14,8 @@ import {
getPaytoPaymentOpenHandlers,
getPaytoTypeInfo
} from '@/lib/payto'
import { cn } from '@/lib/utils'
import LightningInvoiceSection from './LightningInvoiceSection'
export default function PaytoDialog({
open,
@ -44,61 +46,84 @@ export default function PaytoDialog({ @@ -44,61 +46,84 @@ export default function PaytoDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{isLightning && <Zap className="size-5 text-yellow-400" />}
<span>{label}</span>
<DialogContent
className={cn(
'left-[50%] top-[50%] flex w-[calc(100vw-1.25rem)] max-w-md translate-x-[-50%] translate-y-[-50%] flex-col gap-0',
'max-h-[min(92dvh,720px)] overflow-x-hidden overflow-y-auto p-0 sm:max-w-md sm:p-0',
'pb-[max(0.75rem,env(safe-area-inset-bottom))]'
)}
>
<DialogHeader className="shrink-0 space-y-1 border-b border-border/60 px-4 pb-3 pt-4 text-left sm:px-5 sm:pt-5">
<DialogTitle className="flex min-w-0 items-center gap-2 pr-8 text-lg sm:text-xl">
{isLightning && <Zap className="size-6 shrink-0 text-yellow-400" />}
<span className="truncate">{label}</span>
</DialogTitle>
<DialogDescription>
<DialogDescription className="text-left text-sm leading-relaxed sm:text-base">
{isLightning
? t('Lightning payment address – copy to pay via your wallet')
? t('Create a BOLT11 invoice from this Lightning address, then pay in your connected wallet or another app.')
: t('Payment address – copy to use in your wallet or app')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 pb-2">
<div className="rounded-md bg-muted px-3 py-2 font-mono text-sm break-all select-text">
{authority}
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="default"
size="sm"
onClick={() => handleCopy(authority, label)}
className="gap-2"
>
<Copy className="size-4" />
{t('Copy address')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => handleCopy(paytoUri)}
className="gap-2"
>
<Copy className="size-4" />
{t('Copy payto URI')}
</Button>
</div>
<div className="min-w-0 space-y-4 px-4 py-4 sm:px-5">
{isLightning ? (
<LightningInvoiceSection lightningAddress={authority} paytoUri={paytoUri} />
) : (
<>
<div className="min-w-0 rounded-lg bg-muted/40 px-3 py-2.5 ring-1 ring-border/50">
<p className="mb-1.5 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t('Payment address')}
</p>
<p className="break-all font-mono text-base leading-relaxed select-text sm:text-lg">{authority}</p>
</div>
<div className="grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-2">
<Button
variant="default"
className="h-11 w-full min-w-0 gap-2 text-base"
onClick={() => handleCopy(authority, label)}
>
<Copy className="size-5 shrink-0" />
<span className="truncate">{t('Copy address')}</span>
</Button>
<Button
variant="secondary"
className="h-11 w-full min-w-0 gap-2 text-base"
onClick={() => handleCopy(paytoUri)}
>
<Copy className="size-5 shrink-0" />
<span className="truncate">{t('Copy payto URI')}</span>
</Button>
</div>
</>
)}
{openHandlers.length > 0 && (
<div className="space-y-2 border-t border-border pt-3">
<p className="text-sm font-medium text-muted-foreground">{t('Open with')}</p>
<div className="flex flex-wrap gap-2">
<div className="min-w-0 space-y-2.5 border-t border-border/60 pt-4">
<p className="text-sm font-semibold uppercase tracking-wider text-muted-foreground sm:text-base">
{t('Open with')}
</p>
<div className="grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-2">
{openHandlers.map((handler) => (
<Button key={handler.id} variant="outline" size="sm" asChild className="gap-2">
<Button
key={handler.id}
variant="outline"
className="h-11 w-full min-w-0 justify-start gap-2 px-3 text-base"
asChild
>
<a
href={handler.href}
className="flex min-w-0 items-center"
{...(handler.isHttp
? { target: '_blank', rel: 'noopener noreferrer' }
: {})}
onClick={(e) => e.stopPropagation()}
>
{handler.isHttp ? (
<ExternalLink className="size-4" />
<ExternalLink className="size-5 shrink-0" />
) : (
<Wallet className="size-4" />
<Wallet className="size-5 shrink-0" />
)}
{t('Open in {{name}}', { name: handler.openTargetName })}
<span className="truncate">{t('Open in {{name}}', { name: handler.openTargetName })}</span>
</a>
</Button>
))}

5
src/constants.ts

@ -602,9 +602,12 @@ export const ExtendedKind = { @@ -602,9 +602,12 @@ export const ExtendedKind = {
EVENTS_I_MUTED_NOTIFICATIONS_LIST: 19132
}
/** NIP-57 send flow (wallet connect, zap dialog, note zap button). Kind 9735 receipts and payment targets stay visible when false. */
/** NIP-57 send flow (zap dialog, note zap button, public zap receipts). Payment targets stay visible when false. */
export const ZAP_SENDING_ENABLED = false
/** WebLN wallet connect + pay BOLT11 from LNURL-pay invoices (no NIP-57 zaps). */
export const LIGHTNING_WALLET_PAY_ENABLED = true
/** Kind 0 + NIP-A3 payment: publish to profile mirrors, full outbox (NIP-65 + HTTP + cache), and IndexedDB. */
export function isAuthorProfileMetadataPublishKind(kind: number): boolean {
return kind === kinds.Metadata || kind === ExtendedKind.PAYMENT_INFO

17
src/i18n/locales/en.ts

@ -124,11 +124,28 @@ export default { @@ -124,11 +124,28 @@ export default {
"Copied to clipboard": "Copied to clipboard",
"Copied {{label}} address": "Copied {{label}} address",
"Lightning payment address – copy to pay via your wallet": "Lightning payment address – copy to pay via your wallet",
"Payment address": "Payment address",
"Payment address – copy to use in your wallet or app": "Payment address – copy to use in your wallet or app",
"Click to open payment options": "Click to open payment options",
"Click to copy address": "Click to copy address",
"Open on website": "Open on website",
"Open in wallet": "Open in wallet",
"Create invoice": "Create invoice",
"Copy invoice": "Copy invoice",
"Failed to create invoice": "Failed to create invoice",
"Amount (sats)": "Amount (sats)",
"Pay with connected wallet": "Pay with connected wallet",
"Payment sent": "Payment sent",
"Pay via a BOLT11 invoice or copy the Lightning address": "Pay via a BOLT11 invoice or copy the Lightning address",
"Create a BOLT11 invoice from this Lightning address, then pay in your connected wallet or another app.": "Create a BOLT11 invoice from this Lightning address, then pay in your connected wallet or another app.",
"Description (optional)": "Description (optional)",
"Payment description": "Payment description",
"This address does not support payment descriptions.": "This address does not support payment descriptions.",
"Lightning payment": "Lightning payment",
"Invoice ready": "Invoice ready",
"BOLT11 invoice": "BOLT11 invoice",
"Scan to pay with a Lightning wallet": "Scan to pay with a Lightning wallet",
"Regenerate invoice": "Regenerate invoice",
"Open in {{name}}": "Open in {{name}}",
"Open with": "Open with",
"See reports": "See reports",

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

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { RefreshButton } from '@/components/RefreshButton'
import { ZAP_SENDING_ENABLED } from '@/constants'
import { LIGHTNING_WALLET_PAY_ENABLED } from '@/constants'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { forwardRef, useCallback, useEffect, useState } from 'react'
@ -31,7 +31,7 @@ const WalletPage = forwardRef(({ index, hideTitlebar = false }: { index?: number @@ -31,7 +31,7 @@ const WalletPage = forwardRef(({ index, hideTitlebar = false }: { index?: number
controls={hideTitlebar ? undefined : <RefreshButton onClick={bump} />}
>
<div key={contentKey} className="px-4 pt-3 space-y-4">
{ZAP_SENDING_ENABLED ? <WalletZapSendingSettings /> : null}
{LIGHTNING_WALLET_PAY_ENABLED ? <WalletZapSendingSettings /> : null}
<LightningAddressInput />
<ZapReplyThresholdInput />
</div>

4
src/providers/ZapProvider.tsx

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { ZAP_SENDING_ENABLED } from '@/constants'
import { LIGHTNING_WALLET_PAY_ENABLED } from '@/constants'
import lightningService from '@/services/lightning.service'
import storage from '@/services/local-storage.service'
import { onConnected, onDisconnected } from '@getalby/bitcoin-connect-react'
@ -44,7 +44,7 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { @@ -44,7 +44,7 @@ export function ZapProvider({ children }: { children: React.ReactNode }) {
const [walletInfo, setWalletInfo] = useState<GetInfoResponse | null>(null)
useEffect(() => {
if (!ZAP_SENDING_ENABLED) return
if (!LIGHTNING_WALLET_PAY_ENABLED) return
const unSubOnConnected = onConnected((provider) => {
setIsWalletConnected(true)

117
src/services/lightning.service.ts

@ -14,6 +14,7 @@ import client from './client.service' @@ -14,6 +14,7 @@ import client from './client.service'
import storage from './local-storage.service'
import { queryService, replaceableEventService } from './client.service'
import { getProfileFromEvent } from '@/lib/event-metadata'
import { clampZapSats } from '@/lib/lightning'
import { prioritizeZapLightningAddress } from '@/lib/merge-payment-methods'
import { fetchWithTimeout } from '@/lib/fetch-with-timeout'
import logger from '@/lib/logger'
@ -21,6 +22,14 @@ import { runAfterReleasingRadixScrollLock } from '@/lib/react-remove-scroll-body @@ -21,6 +22,14 @@ import { runAfterReleasingRadixScrollLock } from '@/lib/react-remove-scroll-body
export type TRecentSupporter = { pubkey: string; amount: number; comment?: string }
/** LNURL-pay limits from the recipient’s `.well-known/lnurlp` metadata. */
export type LnurlPayInvoiceOptions = {
/** Max description length; `0` means the endpoint does not accept comments. */
commentAllowed: number
minSendableMsat?: number
maxSendableMsat?: number
}
const OFFICIAL_PUBKEYS = [IMWALD_MAINTAINER_PUBKEY, CODY_PUBKEY]
class LightningService {
@ -256,9 +265,86 @@ class LightningService { @@ -256,9 +265,86 @@ class LightningService {
return prioritizeZapLightningAddress(out, preferredFirst)
}
/**
* LNURL-pay metadata for a lightning address (LUD-16 or lnurl bech32).
* Does not require Nostr zap support use {@link createLnurlInvoice} for plain invoices.
*/
async getLnurlPayInvoiceOptions(lightningAddress: string): Promise<LnurlPayInvoiceOptions | null> {
const meta = await this.resolveLnurlPayMetadata(lightningAddress)
if (!meta) return null
return {
commentAllowed: meta.commentAllowed,
minSendableMsat: meta.minSendable,
maxSendableMsat: meta.maxSendable
}
}
async createLnurlInvoice(
lightningAddress: string,
sats: number,
options?: { description?: string }
): Promise<string> {
const meta = await this.resolveLnurlPayMetadata(lightningAddress)
if (!meta) {
throw new Error('Lightning address could not be resolved')
}
const clamped = clampZapSats(sats)
if (clamped < 1) {
throw new Error('Amount must be at least 1 sat')
}
const amountMsat = clamped * 1000
if (meta.minSendable != null && amountMsat < meta.minSendable) {
throw new Error(`Minimum amount is ${Math.ceil(meta.minSendable / 1000)} sats`)
}
if (meta.maxSendable != null && amountMsat > meta.maxSendable) {
throw new Error(`Maximum amount is ${Math.floor(meta.maxSendable / 1000)} sats`)
}
const description = options?.description?.trim() ?? ''
if (description) {
if (meta.commentAllowed < 1) {
throw new Error('This Lightning address does not accept payment descriptions')
}
if (description.length > meta.commentAllowed) {
throw new Error(`Description must be at most ${meta.commentAllowed} characters`)
}
}
const params = new URLSearchParams({ amount: String(amountMsat) })
if (description) {
params.set('comment', description)
}
const res = await fetchWithTimeout(`${meta.callback}?${params.toString()}`, {
timeoutMs: 25_000
})
const body = (await res.json()) as { pr?: string; reason?: string; error?: string; message?: string }
if (body.error) {
throw new Error(body.message ?? String(body.error))
}
if (!body.pr) {
throw new Error(body.reason ?? 'Failed to create invoice')
}
return body.pr
}
private async fetchLnurlPayZapEndpoint(lightningAddress: string): Promise<null | {
callback: string
lnurl: string
}> {
const meta = await this.resolveLnurlPayMetadata(lightningAddress)
if (!meta?.allowsNostr || !meta.nostrPubkey) return null
return { callback: meta.callback, lnurl: meta.lnurl }
}
private async resolveLnurlPayMetadata(lightningAddress: string): Promise<null | {
callback: string
lnurl: string
allowsNostr: boolean
nostrPubkey?: string
commentAllowed: number
minSendable?: number
maxSendable?: number
}> {
try {
let lnurl = ''
@ -285,9 +371,16 @@ class LightningService { @@ -285,9 +371,16 @@ class LightningService {
}
const text = await res.text()
let body: { allowsNostr?: unknown; nostrPubkey?: unknown; callback?: unknown }
let body: {
allowsNostr?: unknown
nostrPubkey?: unknown
callback?: unknown
commentAllowed?: unknown
minSendable?: unknown
maxSendable?: unknown
}
try {
body = JSON.parse(text) as { allowsNostr?: unknown; nostrPubkey?: unknown; callback?: unknown }
body = JSON.parse(text) as typeof body
} catch {
logger.warn('LNURL-pay metadata was not valid JSON (HTML error page or empty redirect target?)', {
lnurl,
@ -297,11 +390,21 @@ class LightningService { @@ -297,11 +390,21 @@ class LightningService {
return null
}
if (body.allowsNostr && body.nostrPubkey && typeof body.callback === 'string') {
return {
callback: body.callback,
lnurl
}
if (typeof body.callback !== 'string' || !body.callback) return null
const commentAllowed =
typeof body.commentAllowed === 'number' && body.commentAllowed >= 0
? Math.floor(body.commentAllowed)
: 0
return {
callback: body.callback,
lnurl,
allowsNostr: Boolean(body.allowsNostr && body.nostrPubkey),
nostrPubkey: typeof body.nostrPubkey === 'string' ? body.nostrPubkey : undefined,
commentAllowed,
minSendable: typeof body.minSendable === 'number' ? body.minSendable : undefined,
maxSendable: typeof body.maxSendable === 'number' ? body.maxSendable : undefined
}
} catch (err) {
const failedFetch =

Loading…
Cancel
Save