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 ? (