Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
150 changes: 150 additions & 0 deletions src/hooks/useLnurlSession.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
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 abortRef = useRef<AbortController | null>(null)
const onInvoiceRequestRef = useRef(onInvoiceRequest)
onInvoiceRequestRef.current = onInvoiceRequest

const postInvoice = useCallback(async (sessionId: string, pr: string) => {
const response = await fetch(`${lnurlServerBaseUrl}/lnurl/session/${sessionId}/invoice`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pr }),
})
if (!response.ok) {
throw new Error(`Failed to post invoice: ${response.status}`)
}
}, [])

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
setLnurl(data.lnurl as string)
setActive(true)
setError(undefined)
} else if (eventType === 'invoice_request') {
const amountMsat = Number(data.amountMsat)
if (!amountMsat || amountMsat <= 0) {
consoleError('Invalid amountMsat in invoice request:', data.amountMsat)
eventType = ''
continue
}
try {
const pr = await onInvoiceRequestRef.current({
amountMsat,
comment: data.comment as string | undefined,
})
if (sessionIdRef.current) {
await postInvoice(sessionIdRef.current, pr)
}
} catch (err) {
consoleError(err, 'Failed to handle invoice request')
setError('Failed to fulfill payment request')
setLnurl('')
}
}

eventType = ''
}
}
}
} catch (err) {
if (!abort.signal.aborted) {
consoleError(err, 'LNURL session error')
setError('LNURL session disconnected')
}
} finally {
setActive(false)
setLnurl('')
sessionIdRef.current = null
}
}

connect()

return () => {
abort.abort()
abortRef.current = null
}
}, [enabled, postInvoice])

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
39 changes: 34 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 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)
Expand Down Expand Up @@ -51,6 +52,32 @@ 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)
if (!validLnSwap(sats)) throw new Error('Amount outside Lightning receive limits')
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, validLnSwap, 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 +180,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 +270,7 @@ export default function ReceiveQRCode() {
boardingAddr={btcAddress}
offchainAddr={arkAddress}
invoice={invoice || ''}
lnurl={lnurlSession.lnurl}
onClick={setQrCodeValue}
/>
{swapsTimedOut && !invoice && !isAssetReceive ? (
Expand Down
Loading