Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components/ExpandAddresses.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface ExpandAddressesProps {
boardingAddr: string
offchainAddr: string
invoice: string
lnurl?: string
onClick: (arg0: string) => void
}

Expand All @@ -27,6 +28,7 @@ export default function ExpandAddresses({
boardingAddr,
offchainAddr,
invoice,
lnurl,
onClick,
}: ExpandAddressesProps) {
const [copied, setCopied] = useState('')
Expand Down Expand Up @@ -99,6 +101,7 @@ export default function ExpandAddresses({
{boardingAddr ? <ExpandLine testId='btc' title='BTC address' value={boardingAddr} /> : null}
{offchainAddr ? <ExpandLine testId='ark' title='Arkade address' value={offchainAddr} /> : null}
{invoice ? <ExpandLine testId='invoice' title='Lightning invoice' value={invoice} /> : null}
{lnurl ? <ExpandLine testId='lnurl' title='LNURL' value={lnurl} /> : null}
</FlexCol>
</div>
) : null}
Expand Down
190 changes: 190 additions & 0 deletions src/hooks/useLnurlSession.ts
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
}
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 }
}
6 changes: 3 additions & 3 deletions src/lib/bip21.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))}` : '')
)
}

Expand Down
1 change: 1 addition & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 33 additions & 5 deletions src/screens/Wallet/Receive/QrCode.tsx
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'
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "*.ts" -o -name "*.tsx" | xargs grep -l "encodeBip21" | head -20

Repository: arkade-os/wallet

Length of output: 147


🏁 Script executed:

cat -n src/lib/bip21.ts

Repository: arkade-os/wallet

Length of output: 3399


🏁 Script executed:

cat -n src/test/lib/bip21.test.ts

Repository: arkade-os/wallet

Length of output: 2137


🌐 Web query:

BIP321 lightning payment instruction LNURL specification

💡 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 lightning= instead of a separate key, violating BIP-0321 compliance.

Line 80 in src/lib/bip21.ts encodes LNURL as &lightning=${lnurl} when no invoice is present. BIP-0321 reserves the lightning parameter specifically for BOLT11 invoices; LNURL uses its own separate lnurl: URI scheme. This conflation works due to prefix detection in decodeBip21 (checking whether the value starts with "lnurl" or "ln"), but it creates non-standard QR codes that may not parse correctly in wallets expecting strict BIP-0321 compliance.


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
Expand Down Expand Up @@ -242,6 +269,7 @@ export default function ReceiveQRCode() {
boardingAddr={btcAddress}
offchainAddr={arkAddress}
invoice={invoice || ''}
lnurl={lnurlSession.lnurl}
onClick={setQrCodeValue}
/>
{swapsTimedOut && !invoice && !isAssetReceive ? (
Expand Down
Loading