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

40
src/components/PaytoDialog/LightningInvoiceSection.tsx

@ -36,10 +36,13 @@ export default function LightningInvoiceSection({ @@ -36,10 +36,13 @@ export default function LightningInvoiceSection({
paytoUri: string
}) {
const { t } = useTranslation()
const { defaultZapSats, defaultZapComment, isWalletConnected } = useZap()
const { defaultZapSats, isWalletConnected } = useZap()
const [sats, setSats] = useState(() => clampZapSats(defaultZapSats))
const [description, setDescription] = useState(defaultZapComment)
const [description, setDescription] = useState('')
const [commentMax, setCommentMax] = useState<number | null>(null)
const [lnurlMetadataState, setLnurlMetadataState] = useState<'loading' | 'ready' | 'error'>(
'loading'
)
const [invoice, setInvoice] = useState<string | null>(null)
const [invoiceDescription, setInvoiceDescription] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
@ -47,18 +50,27 @@ export default function LightningInvoiceSection({ @@ -47,18 +50,27 @@ export default function LightningInvoiceSection({
useEffect(() => {
setSats(clampZapSats(defaultZapSats))
setDescription(defaultZapComment)
setDescription('')
setInvoice(null)
setInvoiceDescription(null)
setCommentMax(null)
setLnurlMetadataState('loading')
let cancelled = false
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 () => {
cancelled = true
}
}, [lightningAddress, defaultZapSats, defaultZapComment])
}, [lightningAddress, defaultZapSats])
useEffect(() => {
setInvoice(null)
@ -171,23 +183,31 @@ export default function LightningInvoiceSection({ @@ -171,23 +183,31 @@ export default function LightningInvoiceSection({
</div>
</div>
{commentMax === null ? (
{lnurlMetadataState === 'loading' ? (
<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="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}
{description.length}/{commentMax ?? 0}
</span>
</div>
<Textarea
id="ln-invoice-description"
value={description}
onChange={(e) => setDescription(e.target.value.slice(0, commentMax))}
maxLength={commentMax}
onChange={(e) =>
setDescription(e.target.value.slice(0, commentMax ?? 0))
}
maxLength={commentMax ?? 0}
rows={3}
placeholder={t('Payment description')}
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({ @@ -26,7 +26,8 @@ export default function PaytoDialog({
type,
authority,
paytoUri,
recipientPubkey
recipientPubkey,
offerTipNoticeOnClose = true
}: {
open: boolean
onOpenChange: (open: boolean) => void
@ -35,6 +36,8 @@ export default function PaytoDialog({ @@ -35,6 +36,8 @@ export default function PaytoDialog({
paytoUri: string
/** When set, closing the dialog offers a kind-24 tip notice to this pubkey. */
recipientPubkey?: string
/** When false, a parent (e.g. ZapDialog) will offer the tip notice on its own close. */
offerTipNoticeOnClose?: boolean
}) {
const { t } = useTranslation()
const { pubkey: selfPubkey } = useNostr()
@ -54,6 +57,7 @@ export default function PaytoDialog({ @@ -54,6 +57,7 @@ export default function PaytoDialog({
}
const maybeOfferTipNoticeOnClose = () => {
if (!offerTipNoticeOnClose) return
if (!recipientPubkey) return
if (skipTipNoticeOnCloseRef.current) return
if (selfPubkey && recipientPubkey === selfPubkey) return

4
src/components/PaytoLink/index.tsx

@ -27,6 +27,7 @@ export default function PaytoLink({ @@ -27,6 +27,7 @@ export default function PaytoLink({
authority: authorityProp,
pubkey,
onOpenZap,
offerTipNoticeOnClose = true,
className,
children,
/** `compact`: `47R4Npvudm... (Monero)` for notes/markup; `full`: show authority as-is (e.g. zap dialog). */
@ -40,6 +41,8 @@ export default function PaytoLink({ @@ -40,6 +41,8 @@ export default function PaytoLink({
/** When set with lightning type, clicking can open Zap dialog via onOpenZap */
pubkey?: string
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
children?: React.ReactNode
displayFormat?: 'compact' | 'full'
@ -152,6 +155,7 @@ export default function PaytoLink({ @@ -152,6 +155,7 @@ export default function PaytoLink({
authority={authority}
paytoUri={raw}
recipientPubkey={pubkey}
offerTipNoticeOnClose={offerTipNoticeOnClose}
/>
)}
</>

23
src/components/ZapDialog/index.tsx

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

2
src/i18n/locales/en.ts

@ -141,6 +141,8 @@ export default { @@ -141,6 +141,8 @@ export default {
"Description (optional)": "Description (optional)",
"Payment description": "Payment description",
"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",
"Invoice ready": "Invoice ready",
"BOLT11 invoice": "BOLT11 invoice",

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

@ -0,0 +1,41 @@ @@ -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 @@ @@ -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()
}

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

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

48
src/services/lightning.service.ts

@ -1,4 +1,10 @@ @@ -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 { TProfile } from '@/types'
import { init, launchPaymentModal } from '@getalby/bitcoin-connect-react'
@ -17,6 +23,7 @@ import { getProfileFromEvent } from '@/lib/event-metadata' @@ -17,6 +23,7 @@ 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 { buildLnurlPayCallbackUrl, parseLnurlCommentAllowed } from '@/lib/lnurl-pay'
import logger from '@/lib/logger'
import { runAfterReleasingRadixScrollLock } from '@/lib/react-remove-scroll-body-cleanup'
@ -36,6 +43,10 @@ class LightningService { @@ -36,6 +43,10 @@ class LightningService {
static instance: LightningService
provider: WebLNProvider | null = null
private recentSupportersCache: TRecentSupporter[] | null = null
private lnurlPayMetadataCache = new Map<
string,
{ fetchedAt: number; meta: NonNullable<Awaited<ReturnType<LightningService['resolveLnurlPayMetadata']>>> }
>()
constructor() {
if (!LightningService.instance) {
@ -57,6 +68,9 @@ class LightningService { @@ -57,6 +68,9 @@ class LightningService {
includePublicReceipt: boolean = storage.getIncludePublicZapReceipt(),
zapLightning?: { address?: string; candidates?: string[] }
): 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) {
throw new Error('You need to be logged in to zap')
}
@ -94,10 +108,12 @@ class LightningService { @@ -94,10 +108,12 @@ class LightningService {
comment
})
const zapRequest = await client.signer.signEvent(zapRequestDraft)
const zapRequestRes = await fetchWithTimeout(
`${callback}?amount=${amount}&nostr=${encodeURI(JSON.stringify(zapRequest))}&lnurl=${lnurl}`,
{ timeoutMs: 25_000 }
)
const zapRequestUrl = buildLnurlPayCallbackUrl(callback, {
amount: String(amount),
nostr: JSON.stringify(zapRequest),
lnurl
})
const zapRequestRes = await fetchWithTimeout(zapRequestUrl, { timeoutMs: 25_000 })
const zapRequestResBody = await zapRequestRes.json()
if (zapRequestResBody.error) {
throw new Error(zapRequestResBody.message)
@ -310,12 +326,13 @@ class LightningService { @@ -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) {
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
})
const body = (await res.json()) as { pr?: string; reason?: string; error?: string; message?: string }
@ -346,6 +363,12 @@ class LightningService { @@ -346,6 +363,12 @@ class LightningService {
minSendable?: 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 {
let lnurl = ''
@ -392,12 +415,9 @@ class LightningService { @@ -392,12 +415,9 @@ class LightningService {
if (typeof body.callback !== 'string' || !body.callback) return null
const commentAllowed =
typeof body.commentAllowed === 'number' && body.commentAllowed >= 0
? Math.floor(body.commentAllowed)
: 0
const commentAllowed = parseLnurlCommentAllowed(body.commentAllowed)
return {
const meta = {
callback: body.callback,
lnurl,
allowsNostr: Boolean(body.allowsNostr && body.nostrPubkey),
@ -406,6 +426,8 @@ class LightningService { @@ -406,6 +426,8 @@ class LightningService {
minSendable: typeof body.minSendable === 'number' ? body.minSendable : undefined,
maxSendable: typeof body.maxSendable === 'number' ? body.maxSendable : undefined
}
this.lnurlPayMetadataCache.set(cacheKey, { fetchedAt: Date.now(), meta })
return meta
} catch (err) {
const failedFetch =
err instanceof TypeError || (err instanceof Error && err.message === 'Failed to fetch')

Loading…
Cancel
Save