diff --git a/src/components/ExpandAddresses.tsx b/src/components/ExpandAddresses.tsx
index 79d965f7..be5759b1 100644
--- a/src/components/ExpandAddresses.tsx
+++ b/src/components/ExpandAddresses.tsx
@@ -19,6 +19,7 @@ interface ExpandAddressesProps {
boardingAddr: string
offchainAddr: string
invoice: string
+ lnurl?: string
onClick: (arg0: string) => void
}
@@ -27,6 +28,7 @@ export default function ExpandAddresses({
boardingAddr,
offchainAddr,
invoice,
+ lnurl,
onClick,
}: ExpandAddressesProps) {
const [copied, setCopied] = useState('')
@@ -99,6 +101,7 @@ export default function ExpandAddresses({
{boardingAddr ? : null}
{offchainAddr ? : null}
{invoice ? : null}
+ {lnurl ? : null}
) : null}
diff --git a/src/hooks/useLnurlSession.ts b/src/hooks/useLnurlSession.ts
new file mode 100644
index 00000000..a16139f8
--- /dev/null
+++ b/src/hooks/useLnurlSession.ts
@@ -0,0 +1,190 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { lnurlServerUrl as rawLnurlServerUrl } from '../lib/constants'
+import { consoleError } from '../lib/logs'
+
+const lnurlServerBaseUrl = rawLnurlServerUrl?.replace(/\/+$/, '')
+
+interface LnurlSession {
+ /** LNURL bech32 string to display/share */
+ lnurl: string
+ /** Whether the SSE session is active */
+ active: boolean
+ /** Error message if session failed */
+ error: string | undefined
+}
+
+interface InvoiceRequest {
+ amountMsat: number
+ comment?: string
+}
+
+/**
+ * Hook that manages an LNURL session with the lnurl-server.
+ *
+ * Opens an SSE stream to receive invoice requests from payers.
+ * When a payer requests an invoice, calls `onInvoiceRequest` so the wallet
+ * can create a reverse swap and return the bolt11.
+ *
+ * The session (and LNURL) is active as long as the component is mounted.
+ */
+export function useLnurlSession(
+ enabled: boolean,
+ onInvoiceRequest: (req: InvoiceRequest) => Promise,
+): LnurlSession {
+ const [lnurl, setLnurl] = useState('')
+ const [active, setActive] = useState(false)
+ const [error, setError] = useState()
+ const sessionIdRef = useRef(null)
+ const tokenRef = useRef(null)
+ const abortRef = useRef(null)
+ const onInvoiceRequestRef = useRef(onInvoiceRequest)
+ onInvoiceRequestRef.current = onInvoiceRequest
+
+ const authHeaders = useCallback(
+ () => ({
+ 'Content-Type': 'application/json',
+ ...(tokenRef.current ? { Authorization: `Bearer ${tokenRef.current}` } : {}),
+ }),
+ [],
+ )
+
+ const postInvoice = useCallback(
+ async (sessionId: string, pr: string, signal?: AbortSignal) => {
+ const response = await fetch(`${lnurlServerBaseUrl}/lnurl/session/${sessionId}/invoice`, {
+ method: 'POST',
+ headers: authHeaders(),
+ body: JSON.stringify({ pr }),
+ signal,
+ })
+ if (!response.ok) {
+ throw new Error(`Failed to post invoice: ${response.status}`)
+ }
+ },
+ [authHeaders],
+ )
+
+ const postError = useCallback(
+ async (sessionId: string, reason: string, signal?: AbortSignal) => {
+ try {
+ const response = await fetch(`${lnurlServerBaseUrl}/lnurl/session/${sessionId}/invoice`, {
+ method: 'POST',
+ headers: authHeaders(),
+ body: JSON.stringify({ error: reason }),
+ signal,
+ })
+ if (!response.ok) {
+ consoleError(
+ `Failed to post error to lnurl-server: ${response.status} ${await response.text().catch(() => '')}`,
+ )
+ }
+ } catch (err) {
+ if (!(err instanceof DOMException && err.name === 'AbortError')) {
+ consoleError(err, 'Failed to post error to lnurl-server')
+ }
+ }
+ },
+ [authHeaders],
+ )
+
+ useEffect(() => {
+ if (!enabled || !lnurlServerBaseUrl) return
+
+ const abort = new AbortController()
+ abortRef.current = abort
+
+ const connect = async () => {
+ try {
+ const response = await fetch(`${lnurlServerBaseUrl}/lnurl/session`, {
+ method: 'POST',
+ signal: abort.signal,
+ })
+
+ if (!response.ok || !response.body) {
+ setError('Failed to open LNURL session')
+ return
+ }
+
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ''
+ let eventType = ''
+
+ while (!abort.signal.aborted) {
+ const { done, value } = await reader.read()
+ if (done) break
+
+ buffer += decoder.decode(value, { stream: true })
+ const lines = buffer.split('\n')
+ // Keep the last incomplete line in the buffer
+ buffer = lines.pop() ?? ''
+
+ for (const line of lines) {
+ if (line.startsWith('event: ')) {
+ eventType = line.slice(7).trim()
+ } else if (line.startsWith('data: ') && eventType) {
+ let data: Record
+ try {
+ data = JSON.parse(line.slice(6))
+ } catch {
+ consoleError('Failed to parse SSE data:', line)
+ eventType = ''
+ continue
+ }
+
+ if (eventType === 'session_created') {
+ sessionIdRef.current = data.sessionId as string
+ tokenRef.current = data.token as string
+ setLnurl(data.lnurl as string)
+ setActive(true)
+ setError(undefined)
+ } else if (eventType === 'invoice_request') {
+ const sessionId = sessionIdRef.current
+ if (!sessionId) break
+
+ const amountMsat = Number(data.amountMsat)
+ if (!amountMsat || amountMsat <= 0) {
+ consoleError('Invalid amountMsat in invoice request:', data.amountMsat)
+ await postError(sessionId, 'Invalid amount', abort.signal)
+ eventType = ''
+ continue
+ }
+ try {
+ const pr = await onInvoiceRequestRef.current({
+ amountMsat,
+ comment: data.comment as string | undefined,
+ })
+ await postInvoice(sessionId, pr, abort.signal)
+ } catch (err) {
+ const reason = err instanceof Error ? err.message : 'Failed to create invoice'
+ consoleError(err, 'Failed to handle invoice request')
+ await postError(sessionId, reason, abort.signal)
+ }
+ }
+
+ eventType = ''
+ }
+ }
+ }
+ } catch (err) {
+ if (!abort.signal.aborted) {
+ consoleError(err, 'LNURL session error')
+ setError('LNURL session disconnected')
+ }
+ } finally {
+ setActive(false)
+ setLnurl('')
+ sessionIdRef.current = null
+ tokenRef.current = null
+ }
+ }
+
+ connect()
+
+ return () => {
+ abort.abort()
+ abortRef.current = null
+ }
+ }, [enabled, postInvoice, postError])
+
+ return { lnurl, active, error }
+}
diff --git a/src/lib/bip21.ts b/src/lib/bip21.ts
index 7ccdb0cb..56fc067c 100644
--- a/src/lib/bip21.ts
+++ b/src/lib/bip21.ts
@@ -73,12 +73,12 @@ export const decodeBip21 = (uri: string): Bip21Decoded => {
return result
}
-export const encodeBip21 = (address: string, arkAddress: string, invoice: string, sats: number) => {
+export const encodeBip21 = (address: string, arkAddress: string, invoice: string, sats: number, lnurl?: string) => {
return (
`bitcoin:${address}` +
`?ark=${arkAddress}` +
- (invoice ? `&lightning=${invoice}` : '') +
- `&amount=${prettyNumber(fromSatoshis(sats))}`
+ (invoice ? `&lightning=${invoice}` : lnurl ? `&lightning=${lnurl}` : '') +
+ (sats ? `&amount=${prettyNumber(fromSatoshis(sats))}` : '')
)
}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index 4fb86460..38a6e72a 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -12,6 +12,7 @@ export const minSatsToNudge = 100_000
export const maxPercentage = import.meta.env.VITE_MAX_PERCENTAGE ?? 10
export const psaMessage = import.meta.env.VITE_PSA_MESSAGE ?? ''
export const enableChainSwapsReceive = import.meta.env.VITE_CHAIN_SWAPS_RECEIVE_ENABLED === 'true'
+export const lnurlServerUrl: string | undefined = import.meta.env.VITE_LNURL_SERVER_URL
export const defaultArkServer = () => {
if (import.meta.env.VITE_ARK_SERVER) return import.meta.env.VITE_ARK_SERVER
diff --git a/src/screens/Wallet/Receive/QrCode.tsx b/src/screens/Wallet/Receive/QrCode.tsx
index 6787f207..0e55d2e9 100644
--- a/src/screens/Wallet/Receive/QrCode.tsx
+++ b/src/screens/Wallet/Receive/QrCode.tsx
@@ -1,4 +1,4 @@
-import { useContext, useEffect, useState } from 'react'
+import { useCallback, useContext, useEffect, useState } from 'react'
import Button from '../../../components/Button'
import Padded from '../../../components/Padded'
import QrCode from '../../../components/QrCode'
@@ -19,10 +19,11 @@ import LoadingLogo from '../../../components/LoadingLogo'
import { SwapsContext } from '../../../providers/swaps'
import { encodeBip21, encodeBip21Asset } from '../../../lib/bip21'
import { PendingChainSwap, PendingReverseSwap } from '@arkade-os/boltz-swap'
-import { enableChainSwapsReceive } from '../../../lib/constants'
+import { enableChainSwapsReceive, lnurlServerUrl } from '../../../lib/constants'
import { centsToUnits } from '../../../lib/assets'
import WarningBox from '../../../components/Warning'
import ErrorMessage from '../../../components/Error'
+import { useLnurlSession } from '../../../hooks/useLnurlSession'
export default function ReceiveQRCode() {
const { navigate } = useContext(NavigationContext)
@@ -51,6 +52,31 @@ export default function ReceiveQRCode() {
const [bip21Uri, setBip21Uri] = useState('')
const [invoice, setInvoice] = useState('')
+ // LNURL session for amountless Lightning receives
+ const isAmountlessLnurl =
+ !satoshis && !isAssetReceive && !!lnurlServerUrl && connected && !!arkadeSwaps && !swapsInitError
+ const handleInvoiceRequest = useCallback(
+ async (req: { amountMsat: number }) => {
+ const sats = Math.floor(req.amountMsat / 1000)
+ const pendingSwap = await createReverseSwap(sats)
+ if (!pendingSwap) throw new Error('Failed to create reverse swap')
+ // Auto-claim in background
+ if (arkadeSwaps) {
+ arkadeSwaps
+ .waitAndClaim(pendingSwap)
+ .then(() => {
+ setRecvInfo({ ...recvInfo, satoshis: pendingSwap.response.onchainAmount })
+ notifyPaymentReceived(pendingSwap.response.onchainAmount)
+ navigate(Pages.ReceiveSuccess)
+ })
+ .catch((err) => consoleError(err, 'Error claiming LNURL reverse swap'))
+ }
+ return pendingSwap.response.invoice
+ },
+ [arkadeSwaps, createReverseSwap, setRecvInfo, recvInfo, navigate, notifyPaymentReceived],
+ )
+ const lnurlSession = useLnurlSession(isAmountlessLnurl, handleInvoiceRequest)
+
const createBtcAddress = () => {
return new Promise((resolve, reject) => {
if (!enableChainSwapsReceive) return reject()
@@ -153,14 +179,15 @@ export default function ReceiveQRCode() {
const bip21uri = isAssetReceive
? encodeBip21Asset(arkAddress, assetId, centsToUnits(satoshis, assetMeta?.metadata?.decimals))
- : encodeBip21(btcAddress, arkAddress, invoice, satoshis)
+ : encodeBip21(btcAddress, arkAddress, invoice, satoshis, lnurlSession.lnurl)
- setNoPaymentMethods(!arkAddress && !btcAddress && !invoice && !isAssetReceive)
+ const hasLnurl = isAmountlessLnurl && lnurlSession.active
+ setNoPaymentMethods(!arkAddress && !btcAddress && !invoice && !hasLnurl && !isAssetReceive)
setArkAddress(arkAddress)
setBtcAddress(btcAddress)
setQrCodeValue(bip21uri)
setBip21Uri(bip21uri)
- }, [showQrCode, swapAddress, invoice])
+ }, [showQrCode, swapAddress, invoice, lnurlSession.lnurl, lnurlSession.active, isAmountlessLnurl])
useEffect(() => {
if (!svcWallet) return
@@ -242,6 +269,7 @@ export default function ReceiveQRCode() {
boardingAddr={btcAddress}
offchainAddr={arkAddress}
invoice={invoice || ''}
+ lnurl={lnurlSession.lnurl}
onClick={setQrCodeValue}
/>
{swapsTimedOut && !invoice && !isAssetReceive ? (