-
Notifications
You must be signed in to change notification settings - Fork 22
Add LNURL support for amountless Lightning receives #482
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b72d344
d001ef9
6ab0736
768f71a
f74ec68
a9f56c7
e4f6925
36a866b
7c59f90
2a43831
75868f8
7b38342
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string>, | ||
| ): LnurlSession { | ||
| const [lnurl, setLnurl] = useState('') | ||
| const [active, setActive] = useState(false) | ||
| const [error, setError] = useState<string | undefined>() | ||
| const sessionIdRef = useRef<string | null>(null) | ||
| const tokenRef = useRef<string | null>(null) | ||
| const abortRef = useRef<AbortController | null>(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<string, unknown> | ||
| 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 | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| 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 } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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') | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // 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) | ||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -type f -name "*.ts" -o -name "*.tsx" | xargs grep -l "encodeBip21" | head -20Repository: arkade-os/wallet Length of output: 147 🏁 Script executed: cat -n src/lib/bip21.tsRepository: arkade-os/wallet Length of output: 3399 🏁 Script executed: cat -n src/test/lib/bip21.test.tsRepository: arkade-os/wallet Length of output: 2137 🌐 Web query:
💡 Result: BIP-0321 defines the bitcoin: URI scheme for Bitcoin payment instructions, replacing BIP-0021. It supports Lightning payments via the 'lightning' query parameter containing BOLT11 invoices (e.g., bitcoin:?lightning=lnbc420bogusinvoice). No direct integration or specification for LNURL (a separate Lightning protocol using lnurl: URIs and HTTP callbacks for dynamic invoices) exists in BIP-0321. LNURL is handled via its own 'lnurl:' URI scheme or Lightning Addresses, distinct from BIP-0321 bitcoin: URIs. Wallets may support both independently for unified QR codes or payment requests, but they are not formally linked in the specs. Citations:
🏁 Script executed: cat -n src/screens/Wallet/Receive/QrCode.tsx | sed -n '170,195p'Repository: arkade-os/wallet Length of output: 1216 encodeBip21 serializes LNURL as Line 80 in |
||
|
|
||
| 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 ? ( | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.