-
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 2 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,134 @@ | ||
| import { useCallback, useEffect, useRef, useState } from 'react' | ||
| import { lnurlServerUrl } from '../lib/constants' | ||
| import { consoleError } from '../lib/logs' | ||
|
|
||
| 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 abortRef = useRef<AbortController | null>(null) | ||
| const onInvoiceRequestRef = useRef(onInvoiceRequest) | ||
| onInvoiceRequestRef.current = onInvoiceRequest | ||
|
|
||
| const postInvoice = useCallback(async (sessionId: string, pr: string) => { | ||
| try { | ||
| await fetch(`${lnurlServerUrl}/lnurl/session/${sessionId}/invoice`, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ pr }), | ||
| }) | ||
| } catch (err) { | ||
| consoleError(err, 'Failed to post invoice to lnurl-server') | ||
| } | ||
| }, []) | ||
|
|
||
| useEffect(() => { | ||
| if (!enabled || !lnurlServerUrl) return | ||
|
|
||
| const abort = new AbortController() | ||
| abortRef.current = abort | ||
|
|
||
| const connect = async () => { | ||
| try { | ||
| const response = await fetch(`${lnurlServerUrl}/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 = '' | ||
|
|
||
| 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() ?? '' | ||
|
|
||
| let eventType = '' | ||
| for (const line of lines) { | ||
| if (line.startsWith('event: ')) { | ||
| eventType = line.slice(7).trim() | ||
| } else if (line.startsWith('data: ') && eventType) { | ||
| const data = JSON.parse(line.slice(6)) | ||
|
|
||
| if (eventType === 'session_created') { | ||
| sessionIdRef.current = data.sessionId | ||
| setLnurl(data.lnurl) | ||
| setActive(true) | ||
| setError(undefined) | ||
| } else if (eventType === 'invoice_request') { | ||
| const req: InvoiceRequest = { | ||
| amountMsat: data.amountMsat, | ||
| comment: data.comment, | ||
| } | ||
| try { | ||
| const pr = await onInvoiceRequestRef.current(req) | ||
| if (sessionIdRef.current) { | ||
| await postInvoice(sessionIdRef.current, pr) | ||
| } | ||
| } catch (err) { | ||
| consoleError(err, 'Failed to handle invoice request') | ||
| } | ||
| } | ||
|
|
||
| eventType = '' | ||
| } | ||
| } | ||
| } | ||
| } catch (err) { | ||
| if (!abort.signal.aborted) { | ||
| consoleError(err, 'LNURL session error') | ||
| setError('LNURL session disconnected') | ||
| } | ||
| } finally { | ||
| setActive(false) | ||
| sessionIdRef.current = null | ||
| } | ||
| } | ||
|
|
||
| connect() | ||
|
|
||
| return () => { | ||
| abort.abort() | ||
| abortRef.current = null | ||
| } | ||
| }, [enabled, postInvoice]) | ||
|
|
||
| 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 Loading from '../../../components/Loading' | |
| 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,30 @@ export default function ReceiveQRCode() { | |
| const [bip21Uri, setBip21Uri] = useState('') | ||
| const [invoice, setInvoice] = useState('') | ||
|
|
||
| // LNURL session for amountless Lightning receives | ||
| const isAmountlessLnurl = !satoshis && !isAssetReceive && !!lnurlServerUrl | ||
| 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 +178,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]) | ||
|
|
||
| useEffect(() => { | ||
| if (!svcWallet) return | ||
|
|
@@ -242,6 +268,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.