Browse Source

lightning bug-fixes

imwald
Silberengel 4 weeks ago
parent
commit
9b785618f6
  1. 4
      src/components/PaymentMethodsSection/index.tsx
  2. 40
      src/components/PaytoDialog/LightningInvoiceSection.tsx
  3. 6
      src/components/PaytoDialog/index.tsx
  4. 4
      src/components/PaytoLink/index.tsx
  5. 19
      src/components/ZapDialog/index.tsx
  6. 2
      src/i18n/locales/en.ts
  7. 41
      src/lib/lnurl-pay.test.ts
  8. 31
      src/lib/lnurl-pay.ts
  9. 5
      src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx
  10. 48
      src/services/lightning.service.ts

4
src/components/PaymentMethodsSection/index.tsx

@ -11,6 +11,7 @@ export default function PaymentMethodsSection({
groups, groups,
recipientPubkey, recipientPubkey,
onOpenZap, onOpenZap,
offerTipNoticeOnClose = true,
title, title,
className, className,
headerHelpText headerHelpText
@ -19,6 +20,8 @@ export default function PaymentMethodsSection({
recipientPubkey?: string recipientPubkey?: string
/** When set, lightning rows open the zap flow with that address as the default. */ /** When set, lightning rows open the zap flow with that address as the default. */
onOpenZap?: (lightningAuthority: string) => void onOpenZap?: (lightningAuthority: string) => void
/** When false, PaytoDialog defer tip notice to parent (e.g. ZapDialog). */
offerTipNoticeOnClose?: boolean
title?: string title?: string
className?: string className?: string
/** Prominent note above the list (e.g. on-chain Bitcoin eligibility in zap dialog). */ /** Prominent note above the list (e.g. on-chain Bitcoin eligibility in zap dialog). */
@ -67,6 +70,7 @@ export default function PaymentMethodsSection({
? (_pk, authority) => onOpenZap(authority) ? (_pk, authority) => onOpenZap(authority)
: undefined : undefined
} }
offerTipNoticeOnClose={offerTipNoticeOnClose}
className={cn(PRIMARY_LINK_HOVER_CLASS, 'break-all min-w-0 flex-1')} className={cn(PRIMARY_LINK_HOVER_CLASS, 'break-all min-w-0 flex-1')}
> >
{method.authority} {method.authority}

40
src/components/PaytoDialog/LightningInvoiceSection.tsx

@ -36,10 +36,13 @@ export default function LightningInvoiceSection({
paytoUri: string paytoUri: string
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { defaultZapSats, defaultZapComment, isWalletConnected } = useZap() const { defaultZapSats, isWalletConnected } = useZap()
const [sats, setSats] = useState(() => clampZapSats(defaultZapSats)) const [sats, setSats] = useState(() => clampZapSats(defaultZapSats))
const [description, setDescription] = useState(defaultZapComment) const [description, setDescription] = useState('')
const [commentMax, setCommentMax] = useState<number | null>(null) const [commentMax, setCommentMax] = useState<number | null>(null)
const [lnurlMetadataState, setLnurlMetadataState] = useState<'loading' | 'ready' | 'error'>(
'loading'
)
const [invoice, setInvoice] = useState<string | null>(null) const [invoice, setInvoice] = useState<string | null>(null)
const [invoiceDescription, setInvoiceDescription] = useState<string | null>(null) const [invoiceDescription, setInvoiceDescription] = useState<string | null>(null)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
@ -47,18 +50,27 @@ export default function LightningInvoiceSection({
useEffect(() => { useEffect(() => {
setSats(clampZapSats(defaultZapSats)) setSats(clampZapSats(defaultZapSats))
setDescription(defaultZapComment) setDescription('')
setInvoice(null) setInvoice(null)
setInvoiceDescription(null) setInvoiceDescription(null)
setCommentMax(null) setCommentMax(null)
setLnurlMetadataState('loading')
let cancelled = false let cancelled = false
void lightning.getLnurlPayInvoiceOptions(lightningAddress).then((opts) => { void lightning.getLnurlPayInvoiceOptions(lightningAddress).then((opts) => {
if (!cancelled) setCommentMax(opts?.commentAllowed ?? 0) if (!cancelled) {
if (opts) {
setCommentMax(opts.commentAllowed)
setLnurlMetadataState('ready')
} else {
setCommentMax(0)
setLnurlMetadataState('error')
}
}
}) })
return () => { return () => {
cancelled = true cancelled = true
} }
}, [lightningAddress, defaultZapSats, defaultZapComment]) }, [lightningAddress, defaultZapSats])
useEffect(() => { useEffect(() => {
setInvoice(null) setInvoice(null)
@ -171,23 +183,31 @@ export default function LightningInvoiceSection({
</div> </div>
</div> </div>
{commentMax === null ? ( {lnurlMetadataState === 'loading' ? (
<Skeleton className="h-[4.5rem] w-full rounded-lg" aria-hidden /> <Skeleton className="h-[4.5rem] w-full rounded-lg" aria-hidden />
) : commentMax > 0 ? ( ) : lnurlMetadataState === 'error' ? (
<p className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2.5 text-sm leading-relaxed text-muted-foreground sm:text-base">
{t(
'Could not read this Lightning address (network or browser block). Descriptions need LNURL-pay support on the recipient side.'
)}
</p>
) : (commentMax ?? 0) > 0 ? (
<div className="min-w-0 space-y-2"> <div className="min-w-0 space-y-2">
<div className="flex items-baseline justify-between gap-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"> <Label htmlFor="ln-invoice-description" className="text-sm font-medium text-muted-foreground sm:text-base">
{t('Description (optional)')} {t('Description (optional)')}
</Label> </Label>
<span className="shrink-0 text-sm tabular-nums text-muted-foreground"> <span className="shrink-0 text-sm tabular-nums text-muted-foreground">
{description.length}/{commentMax} {description.length}/{commentMax ?? 0}
</span> </span>
</div> </div>
<Textarea <Textarea
id="ln-invoice-description" id="ln-invoice-description"
value={description} value={description}
onChange={(e) => setDescription(e.target.value.slice(0, commentMax))} onChange={(e) =>
maxLength={commentMax} setDescription(e.target.value.slice(0, commentMax ?? 0))
}
maxLength={commentMax ?? 0}
rows={3} rows={3}
placeholder={t('Payment description')} placeholder={t('Payment description')}
className="min-h-[5rem] resize-none text-base leading-relaxed sm:text-lg" className="min-h-[5rem] resize-none text-base leading-relaxed sm:text-lg"

6
src/components/PaytoDialog/index.tsx

@ -26,7 +26,8 @@ export default function PaytoDialog({
type, type,
authority, authority,
paytoUri, paytoUri,
recipientPubkey recipientPubkey,
offerTipNoticeOnClose = true
}: { }: {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
@ -35,6 +36,8 @@ export default function PaytoDialog({
paytoUri: string paytoUri: string
/** When set, closing the dialog offers a kind-24 tip notice to this pubkey. */ /** When set, closing the dialog offers a kind-24 tip notice to this pubkey. */
recipientPubkey?: string recipientPubkey?: string
/** When false, a parent (e.g. ZapDialog) will offer the tip notice on its own close. */
offerTipNoticeOnClose?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { pubkey: selfPubkey } = useNostr() const { pubkey: selfPubkey } = useNostr()
@ -54,6 +57,7 @@ export default function PaytoDialog({
} }
const maybeOfferTipNoticeOnClose = () => { const maybeOfferTipNoticeOnClose = () => {
if (!offerTipNoticeOnClose) return
if (!recipientPubkey) return if (!recipientPubkey) return
if (skipTipNoticeOnCloseRef.current) return if (skipTipNoticeOnCloseRef.current) return
if (selfPubkey && recipientPubkey === selfPubkey) return if (selfPubkey && recipientPubkey === selfPubkey) return

4
src/components/PaytoLink/index.tsx

@ -27,6 +27,7 @@ export default function PaytoLink({
authority: authorityProp, authority: authorityProp,
pubkey, pubkey,
onOpenZap, onOpenZap,
offerTipNoticeOnClose = true,
className, className,
children, children,
/** `compact`: `47R4Npvudm... (Monero)` for notes/markup; `full`: show authority as-is (e.g. zap dialog). */ /** `compact`: `47R4Npvudm... (Monero)` for notes/markup; `full`: show authority as-is (e.g. zap dialog). */
@ -40,6 +41,8 @@ export default function PaytoLink({
/** When set with lightning type, clicking can open Zap dialog via onOpenZap */ /** When set with lightning type, clicking can open Zap dialog via onOpenZap */
pubkey?: string pubkey?: string
onOpenZap?: (pubkey: string, lightningAuthority: string) => void onOpenZap?: (pubkey: string, lightningAuthority: string) => void
/** Passed to PaytoDialog; set false when a parent already offers the tip notice on close. */
offerTipNoticeOnClose?: boolean
className?: string className?: string
children?: React.ReactNode children?: React.ReactNode
displayFormat?: 'compact' | 'full' displayFormat?: 'compact' | 'full'
@ -152,6 +155,7 @@ export default function PaytoLink({
authority={authority} authority={authority}
paytoUri={raw} paytoUri={raw}
recipientPubkey={pubkey} recipientPubkey={pubkey}
offerTipNoticeOnClose={offerTipNoticeOnClose}
/> />
)} )}
</> </>

19
src/components/ZapDialog/index.tsx

@ -192,9 +192,13 @@ export default function ZapDialog({
recipientPayment={recipientPayment} recipientPayment={recipientPayment}
lightningAddressOptions={lightningAddressOptions} lightningAddressOptions={lightningAddressOptions}
canLightningZap={canLightningZap} canLightningZap={canLightningZap}
onBeforeZapDialogClose={(withPublicReceipt) => { onBeforeZapDialogClose={
paymentsOnly
? undefined
: (withPublicReceipt) => {
if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true
}} }
}
/> />
</DrawerContent> </DrawerContent>
<TipPublicMessagePrompt <TipPublicMessagePrompt
@ -228,9 +232,13 @@ export default function ZapDialog({
recipientPayment={recipientPayment} recipientPayment={recipientPayment}
lightningAddressOptions={lightningAddressOptions} lightningAddressOptions={lightningAddressOptions}
canLightningZap={canLightningZap} canLightningZap={canLightningZap}
onBeforeZapDialogClose={(withPublicReceipt) => { onBeforeZapDialogClose={
paymentsOnly
? undefined
: (withPublicReceipt) => {
if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true
}} }
}
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@ -295,6 +303,7 @@ function ZapDialogContent({
<PaymentMethodsSection <PaymentMethodsSection
groups={allPaymentGroups} groups={allPaymentGroups}
recipientPubkey={recipient} recipientPubkey={recipient}
offerTipNoticeOnClose={false}
title={t('Payment methods')} title={t('Payment methods')}
className="rounded-lg border border-border bg-muted/40 p-3 min-w-0" className="rounded-lg border border-border bg-muted/40 p-3 min-w-0"
/> />
@ -414,6 +423,7 @@ function ZapDialogContent({
<PaymentMethodsSection <PaymentMethodsSection
groups={zapAlternativePayments.groups} groups={zapAlternativePayments.groups}
recipientPubkey={recipient} recipientPubkey={recipient}
offerTipNoticeOnClose={false}
title={t('Payment methods')} title={t('Payment methods')}
headerHelpText={ headerHelpText={
zapAlternativePayments.showBitcoinOnChainHint zapAlternativePayments.showBitcoinOnChainHint
@ -552,6 +562,7 @@ function ZapDialogContent({
<PaymentMethodsSection <PaymentMethodsSection
groups={zapAlternativePayments.groups} groups={zapAlternativePayments.groups}
recipientPubkey={recipient} recipientPubkey={recipient}
offerTipNoticeOnClose={false}
title={t('Other payment methods')} title={t('Other payment methods')}
headerHelpText={ headerHelpText={
zapAlternativePayments.showBitcoinOnChainHint zapAlternativePayments.showBitcoinOnChainHint

2
src/i18n/locales/en.ts

@ -141,6 +141,8 @@ export default {
"Description (optional)": "Description (optional)", "Description (optional)": "Description (optional)",
"Payment description": "Payment description", "Payment description": "Payment description",
"This address does not support payment descriptions.": "This address does not support payment descriptions.", "This address does not support payment descriptions.": "This address does not support payment descriptions.",
"Could not read this Lightning address (network or browser block). Descriptions need LNURL-pay support on the recipient side.":
"Could not read this Lightning address (network or browser block). Descriptions need LNURL-pay support on the recipient side.",
"Lightning payment": "Lightning payment", "Lightning payment": "Lightning payment",
"Invoice ready": "Invoice ready", "Invoice ready": "Invoice ready",
"BOLT11 invoice": "BOLT11 invoice", "BOLT11 invoice": "BOLT11 invoice",

41
src/lib/lnurl-pay.test.ts

@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import {
buildLnurlPayCallbackUrl,
parseLnurlCommentAllowed
} from './lnurl-pay'
describe('parseLnurlCommentAllowed', () => {
it('accepts numbers and numeric strings', () => {
expect(parseLnurlCommentAllowed(255)).toBe(255)
expect(parseLnurlCommentAllowed('1024')).toBe(1024)
expect(parseLnurlCommentAllowed('0')).toBe(0)
})
it('returns 0 for missing or invalid values', () => {
expect(parseLnurlCommentAllowed(undefined)).toBe(0)
expect(parseLnurlCommentAllowed('')).toBe(0)
expect(parseLnurlCommentAllowed('nope')).toBe(0)
})
})
describe('buildLnurlPayCallbackUrl', () => {
it('merges params into callbacks that already have a query string', () => {
const out = buildLnurlPayCallbackUrl('https://pay.example/cb?tag=payRequest', {
amount: '21000',
comment: 'hello tip'
})
const url = new URL(out)
expect(url.searchParams.get('tag')).toBe('payRequest')
expect(url.searchParams.get('amount')).toBe('21000')
expect(url.searchParams.get('comment')).toBe('hello tip')
})
it('encodes unicode in comments', () => {
const out = buildLnurlPayCallbackUrl('https://pay.example/cb', {
amount: '1000',
comment: 'café ☕'
})
expect(out).toContain('comment=')
expect(decodeURIComponent(new URL(out).searchParams.get('comment') ?? '')).toBe('café ☕')
})
})

31
src/lib/lnurl-pay.ts

@ -0,0 +1,31 @@
/** LUD-06 / LUD-12 LNURL-pay helpers (comment + callback URL). */
/** Default max comment length when metadata omits `commentAllowed` but we still show the field. */
export const LNURL_PAY_FALLBACK_COMMENT_ALLOWED = 255
export function parseLnurlCommentAllowed(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
return Math.floor(value)
}
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return 0
const n = Number(trimmed)
if (Number.isFinite(n) && n >= 0) return Math.floor(n)
}
return 0
}
/**
* Append LNURL-pay GET params to `callback` (LUD-06). Uses `URL` so existing query strings are preserved.
*/
export function buildLnurlPayCallbackUrl(
callback: string,
params: Record<string, string>
): string {
const url = new URL(callback)
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value)
}
return url.toString()
}

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

@ -10,6 +10,7 @@ import {
AlertDialogTrigger AlertDialogTrigger
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { ZAP_SENDING_ENABLED } from '@/constants'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
import { disconnect, launchModal } from '@getalby/bitcoin-connect-react' import { disconnect, launchModal } from '@getalby/bitcoin-connect-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -52,10 +53,14 @@ export default function WalletZapSendingSettings() {
</AlertDialog> </AlertDialog>
</div> </div>
<DefaultZapAmountInput /> <DefaultZapAmountInput />
{ZAP_SENDING_ENABLED ? (
<>
<DefaultZapCommentInput /> <DefaultZapCommentInput />
<QuickZapSwitch /> <QuickZapSwitch />
<IncludePublicZapReceiptSwitch /> <IncludePublicZapReceiptSwitch />
</> </>
) : null}
</>
) )
} }

48
src/services/lightning.service.ts

@ -1,4 +1,10 @@
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, CODY_PUBKEY, IMWALD_MAINTAINER_PUBKEY } from '@/constants' import {
CODY_PUBKEY,
FAST_READ_RELAY_URLS,
FAST_WRITE_RELAY_URLS,
IMWALD_MAINTAINER_PUBKEY,
ZAP_SENDING_ENABLED
} from '@/constants'
import { getZapInfoFromEvent } from '@/lib/event-metadata' import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { TProfile } from '@/types' import { TProfile } from '@/types'
import { init, launchPaymentModal } from '@getalby/bitcoin-connect-react' import { init, launchPaymentModal } from '@getalby/bitcoin-connect-react'
@ -17,6 +23,7 @@ import { getProfileFromEvent } from '@/lib/event-metadata'
import { clampZapSats } from '@/lib/lightning' import { clampZapSats } from '@/lib/lightning'
import { prioritizeZapLightningAddress } from '@/lib/merge-payment-methods' import { prioritizeZapLightningAddress } from '@/lib/merge-payment-methods'
import { fetchWithTimeout } from '@/lib/fetch-with-timeout' import { fetchWithTimeout } from '@/lib/fetch-with-timeout'
import { buildLnurlPayCallbackUrl, parseLnurlCommentAllowed } from '@/lib/lnurl-pay'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { runAfterReleasingRadixScrollLock } from '@/lib/react-remove-scroll-body-cleanup' import { runAfterReleasingRadixScrollLock } from '@/lib/react-remove-scroll-body-cleanup'
@ -36,6 +43,10 @@ class LightningService {
static instance: LightningService static instance: LightningService
provider: WebLNProvider | null = null provider: WebLNProvider | null = null
private recentSupportersCache: TRecentSupporter[] | null = null private recentSupportersCache: TRecentSupporter[] | null = null
private lnurlPayMetadataCache = new Map<
string,
{ fetchedAt: number; meta: NonNullable<Awaited<ReturnType<LightningService['resolveLnurlPayMetadata']>>> }
>()
constructor() { constructor() {
if (!LightningService.instance) { if (!LightningService.instance) {
@ -57,6 +68,9 @@ class LightningService {
includePublicReceipt: boolean = storage.getIncludePublicZapReceipt(), includePublicReceipt: boolean = storage.getIncludePublicZapReceipt(),
zapLightning?: { address?: string; candidates?: string[] } zapLightning?: { address?: string; candidates?: string[] }
): Promise<{ preimage: string; invoice: string } | null> { ): Promise<{ preimage: string; invoice: string } | null> {
if (!ZAP_SENDING_ENABLED) {
throw new Error('NIP-57 zaps are disabled; use LNURL-pay invoices instead')
}
if (!client.signer) { if (!client.signer) {
throw new Error('You need to be logged in to zap') throw new Error('You need to be logged in to zap')
} }
@ -94,10 +108,12 @@ class LightningService {
comment comment
}) })
const zapRequest = await client.signer.signEvent(zapRequestDraft) const zapRequest = await client.signer.signEvent(zapRequestDraft)
const zapRequestRes = await fetchWithTimeout( const zapRequestUrl = buildLnurlPayCallbackUrl(callback, {
`${callback}?amount=${amount}&nostr=${encodeURI(JSON.stringify(zapRequest))}&lnurl=${lnurl}`, amount: String(amount),
{ timeoutMs: 25_000 } nostr: JSON.stringify(zapRequest),
) lnurl
})
const zapRequestRes = await fetchWithTimeout(zapRequestUrl, { timeoutMs: 25_000 })
const zapRequestResBody = await zapRequestRes.json() const zapRequestResBody = await zapRequestRes.json()
if (zapRequestResBody.error) { if (zapRequestResBody.error) {
throw new Error(zapRequestResBody.message) throw new Error(zapRequestResBody.message)
@ -310,12 +326,13 @@ class LightningService {
} }
} }
const params = new URLSearchParams({ amount: String(amountMsat) }) // Plain LNURL-pay only — never NIP-57 (`nostr`); relay-visible zap receipts use {@link zap} when enabled.
const payParams: Record<string, string> = { amount: String(amountMsat) }
if (description) { if (description) {
params.set('comment', description) payParams.comment = description
} }
const res = await fetchWithTimeout(`${meta.callback}?${params.toString()}`, { const res = await fetchWithTimeout(buildLnurlPayCallbackUrl(meta.callback, payParams), {
timeoutMs: 25_000 timeoutMs: 25_000
}) })
const body = (await res.json()) as { pr?: string; reason?: string; error?: string; message?: string } const body = (await res.json()) as { pr?: string; reason?: string; error?: string; message?: string }
@ -346,6 +363,12 @@ class LightningService {
minSendable?: number minSendable?: number
maxSendable?: number maxSendable?: number
}> { }> {
const cacheKey = lightningAddress.trim().toLowerCase()
const cached = this.lnurlPayMetadataCache.get(cacheKey)
if (cached && Date.now() - cached.fetchedAt < 30_000) {
return cached.meta
}
try { try {
let lnurl = '' let lnurl = ''
@ -392,12 +415,9 @@ class LightningService {
if (typeof body.callback !== 'string' || !body.callback) return null if (typeof body.callback !== 'string' || !body.callback) return null
const commentAllowed = const commentAllowed = parseLnurlCommentAllowed(body.commentAllowed)
typeof body.commentAllowed === 'number' && body.commentAllowed >= 0
? Math.floor(body.commentAllowed)
: 0
return { const meta = {
callback: body.callback, callback: body.callback,
lnurl, lnurl,
allowsNostr: Boolean(body.allowsNostr && body.nostrPubkey), allowsNostr: Boolean(body.allowsNostr && body.nostrPubkey),
@ -406,6 +426,8 @@ class LightningService {
minSendable: typeof body.minSendable === 'number' ? body.minSendable : undefined, minSendable: typeof body.minSendable === 'number' ? body.minSendable : undefined,
maxSendable: typeof body.maxSendable === 'number' ? body.maxSendable : undefined maxSendable: typeof body.maxSendable === 'number' ? body.maxSendable : undefined
} }
this.lnurlPayMetadataCache.set(cacheKey, { fetchedAt: Date.now(), meta })
return meta
} catch (err) { } catch (err) {
const failedFetch = const failedFetch =
err instanceof TypeError || (err instanceof Error && err.message === 'Failed to fetch') err instanceof TypeError || (err instanceof Error && err.message === 'Failed to fetch')

Loading…
Cancel
Save