Skip to content

Add LNURL support for amountless Lightning receives#482

Merged
Kukks merged 12 commits intomasterfrom
feat/lnurl-amountless-receive
Mar 31, 2026
Merged

Add LNURL support for amountless Lightning receives#482
Kukks merged 12 commits intomasterfrom
feat/lnurl-amountless-receive

Conversation

@Kukks
Copy link
Copy Markdown
Contributor

@Kukks Kukks commented Mar 26, 2026

Summary

  • When the user skips the amount on the receive screen and VITE_LNURL_SERVER_URL is configured, the wallet opens an SSE session with arklabshq/lnurl-server and displays the resulting LNURL in the QR code
  • When a payer scans the LNURL and chooses an amount, the wallet creates a reverse swap on-the-fly via boltz and returns the bolt11 invoice
  • The LNURL is embedded in the BIP21 URI via the lightning= parameter and shown in the expanded address list
  • Session automatically deactivates when navigating away (SSE closes)

Changes

File Change
src/lib/constants.ts Add VITE_LNURL_SERVER_URL env var
src/hooks/useLnurlSession.ts New hook: manages SSE session, handles invoice requests
src/screens/Wallet/Receive/QrCode.tsx Integrate LNURL session for amountless receives
src/lib/bip21.ts Add optional lnurl param to encodeBip21, skip amount= when 0
src/components/ExpandAddresses.tsx Show LNURL in expanded address list

Configuration

Set VITE_LNURL_SERVER_URL to the URL of a running lnurl-server instance:

VITE_LNURL_SERVER_URL=https://lnurl.example.com

Test plan

  • Set VITE_LNURL_SERVER_URL and verify LNURL appears in QR when skipping amount
  • Scan LNURL with a Lightning wallet, select amount, verify payment completes
  • Verify LNURL shows in expanded addresses
  • Verify session deactivates when navigating back
  • Verify no regression: receive with amount still works as before
  • Verify no regression: receive without VITE_LNURL_SERVER_URL set works as before

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added LNURL session support for amountless Lightning receives with in-app invoice generation and automatic background processing.
    • Receive flow, QR/BIP21 encoding, and UI now include LNURL when available.
    • Address display updated to show LNURL alongside on-chain and Lightning addresses.

Kukks and others added 2 commits March 26, 2026 16:37
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the user skips the amount on the receive screen and
VITE_LNURL_SERVER_URL is configured, the wallet opens an SSE
session with the lnurl-server and displays the resulting LNURL
in the QR code. When a payer scans and chooses an amount, the
wallet creates a reverse swap on-the-fly and returns the bolt11.

- LNURL embedded in BIP21 URI via lightning= parameter
- LNURL shown in expanded address list
- Session auto-closes when navigating away

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 26, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds LNURL receive support: new streaming useLnurlSession hook, LNURL server URL constant, LNURL fields plumbed into BIP21 and QR UI, ExpandAddresses accepts optional lnurl prop, and Receive screen handles invoice_request → reverse-swap flow.

Changes

Cohort / File(s) Summary
LNURL Session Hook
src/hooks/useLnurlSession.ts
New exported hook implementing a POST streaming session to the LNURL server, line-based event/data parsing, session lifecycle (sessionId/token/lnurl/active/error), invokes onInvoiceRequest for invoice_request events, posts invoices/errors back, and cleans up with AbortController.
Receive screen integration
src/screens/Wallet/Receive/QrCode.tsx
Integrates LNURL session: computes amountless LNURL eligibility, defines handleInvoiceRequest to create/redeem reverse swaps and return PRs, passes lnurl into BIP21/QR generation, adjusts receive-method gating and effect dependencies.
UI prop update
src/components/ExpandAddresses.tsx
ExpandAddressesProps gains optional lnurl?: string; component destructures lnurl and conditionally renders an ExpandLine for LNURL (testId lnurl, title LNURL) when provided.
BIP21 encoding
src/lib/bip21.ts
encodeBip21 signature accepts optional lnurl; lightning query prefers invoice then lnurl, and amount query is included only when sats is truthy.
Config constant
src/lib/constants.ts
Added `export const lnurlServerUrl: string

Sequence Diagram(s)

sequenceDiagram
    participant UI as Wallet UI
    participant Hook as useLnurlSession
    participant LNURL as LNURL Server
    participant Handler as onInvoiceRequest
    participant Swap as Swap Engine

    UI->>Hook: enable session (enabled = true)
    Hook->>LNURL: POST /lnurl/session
    LNURL-->>Hook: stream body (lines)
    LNURL->>Hook: event: session_created\ndata: { sessionId, lnurl }
    Hook->>UI: set lnurl, sessionId, active=true

    LNURL->>Hook: event: invoice_request\ndata: { amountMsat, comment? }
    Hook->>Handler: onInvoiceRequest(amountMsat)
    Handler->>Swap: create reverse swap / waitAndClaim
    Swap-->>Handler: pendingSwap.response.invoice (pr)
    Handler-->>Hook: return pr

    Hook->>LNURL: POST /lnurl/session/{sessionId}/invoice { pr }
    LNURL-->>Hook: ack

    UI->>Hook: disable/unmount
    Hook->>LNURL: abort stream
    Hook->>UI: active=false, clear lnurl/sessionId
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • bordalix
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main feature being added: LNURL support specifically for amountless Lightning receives, which is the core objective of the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/lnurl-amountless-receive

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Mar 26, 2026

Deploying wallet-bitcoin with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7b38342
Status: ✅  Deploy successful!
Preview URL: https://1ecb99aa.wallet-bitcoin.pages.dev
Branch Preview URL: https://feat-lnurl-amountless-receiv.wallet-bitcoin.pages.dev

View logs

@arkanaai
Copy link
Copy Markdown

arkanaai bot commented Mar 26, 2026

🔍 PR Review — arkade-os/wallet#482

LNURL support for amountless Lightning receives

Good feature design — clean separation via the useLnurlSession hook, correct SSE lifecycle management, and the BIP21 integration is well-structured.

Issues

1. Uncaught JSON.parse in SSE handler (src/hooks/useLnurlSession.ts:90)

const data = JSON.parse(line.slice(6))

A malformed SSE payload will throw and kill the entire session loop. Wrap in try/catch — SSE streams from external services can contain unexpected data.

2. No validation on amountMsat (useLnurlSession.ts:95)
The amountMsat from the payer is passed straight through to createReverseSwap(Math.floor(req.amountMsat / 1000)). No bounds checking:

  • Negative/zero values → creates invalid swap
  • Extremely large values → swap that can't be fulfilled
    The LNURL spec defines minSendable/maxSendable — the lnurl-server should enforce these, but the wallet should also validate defensively.

3. No SSE reconnection logic (useLnurlSession.ts)
If the connection drops (network blip, server restart), the session dies silently with setActive(false). The user sees a QR that no longer works with no visual feedback beyond the error state. Consider either:

  • Auto-reconnect with backoff
  • Or at minimum, surfacing the error visibly in the UI so the user knows to refresh

4. useEffect dependency array in QrCode.tsx:180

}, [showQrCode, swapAddress, invoice, lnurlSession.lnurl, lnurlSession.active])

Missing isAmountlessLnurl — if the env var or amount changes mid-render (unlikely but possible with hot-reload), the BIP21 URI won't update correctly.

Nits

  • lnurlServerUrl should probably strip a trailing slash if present (or document that it must not have one), since it's used directly in URL construction like `${lnurlServerUrl}/lnurl/session`
  • The handleInvoiceRequest callback references recvInfo in its deps — if recvInfo changes frequently, this could cause unnecessary LNURL session restarts since useLnurlSession takes the callback as a parameter (mitigated by the ref pattern in the hook, so this is fine in practice)

Cross-repo context

  • This depends on ArkLabsHQ/lnurl-server — the wallet trusts the SSE stream format from that server
  • The reverse swap creation path shares logic with the existing boltz flow (createReverseSwap / waitAndClaim)

Summary

Solid architecture. The JSON.parse crash and missing amount validation are the two actionable items — the rest are hardening suggestions. 👍

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Mar 26, 2026

Deploying wallet-mutinynet with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7b38342
Status: ✅  Deploy successful!
Preview URL: https://911c295e.arkade-wallet.pages.dev
Branch Preview URL: https://feat-lnurl-amountless-receiv.arkade-wallet.pages.dev

View logs

Kukks and others added 2 commits March 26, 2026 16:47
- Wrap JSON.parse in try/catch to prevent SSE stream crash on
  malformed payloads
- Validate amountMsat is a positive number before creating swap
- Strip trailing slash from LNURL server URL
- Add missing isAmountlessLnurl to useEffect dependency array

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The replace_all swapped the alias direction — import the
correct export name from constants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
src/lib/bip21.ts (1)

76-81: Add a regression test for the new QR branches.

The referenced encodeBip21 test still only covers the non-zero-sats invoice case. This change adds both an lnurl fallback and the amount= omission path, so it needs an explicit fixture to keep the unified QR payload stable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/bip21.ts` around lines 76 - 81, Add a regression test for encodeBip21
to cover the new QR branches: one fixture where invoice is empty but lnurl is
provided (ensuring the function returns a payload with &lightning=<lnurl>), and
one fixture where sats is zero/undefined (ensuring the returned payload omits
the &amount= parameter). Locate the encodeBip21 function and add tests that
assert the exact returned string for both cases (lnurl fallback and amount
omission) so the unified QR payload remain stable across changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/hooks/useLnurlSession.ts`:
- Around line 40-49: postInvoice currently ignores HTTP error responses; modify
the function that calls
fetch(`${lnurlServerUrl}/lnurl/session/${sessionId}/invoice`) inside postInvoice
to check the Response.ok flag and treat non-2xx responses as failures: when
response.ok is false (or fetch throws), log the response/error via consoleError
and clear the lnurl session state (call setLnurl(null) or the hook's
session-clearing function) so the UI stops advertising the dead LNURL; ensure
postInvoice returns or throws after clearing state so the finally block does not
leave lnurl populated.

In `@src/screens/Wallet/Receive/QrCode.tsx`:
- Around line 57-61: handleInvoiceRequest currently floors req.amountMsat to
sats and calls createReverseSwap without validating the resulting satoshi
amount; replicate the same guard used in the fixed-amount path by converting
amountMsat to satoshis (rounding appropriately), calling validLnSwap(sats) and
throwing a user-facing error when it returns false before calling
createReverseSwap, so out-of-range or sub-sat requests are rejected early;
update the logic inside handleInvoiceRequest (and the similar block around line
75) to perform this validation and only proceed to createReverseSwap(sats) when
validLnSwap passes.
- Around line 55-77: The LNURL is currently enabled by isAmountlessLnurl
regardless of reverse-swap readiness, which can expose an unusable LNURL; change
the gating so useLnurlSession is only invoked when reverse swaps can actually be
created. Concretely, update the isAmountlessLnurl logic (or introduce a new
boolean like isLnurlReady) to require arkadeSwaps to be initialized and
createReverseSwap to be able to return a non-null pending swap (e.g., check
arkadeSwaps != null and optionally perform a lightweight readiness check or
ensure createReverseSwap won’t return null), and pass that boolean to
useLnurlSession instead of the current isAmountlessLnurl; keep
handleInvoiceRequest, createReverseSwap, arkadeSwaps and useLnurlSession names
to locate the code to change.

---

Nitpick comments:
In `@src/lib/bip21.ts`:
- Around line 76-81: Add a regression test for encodeBip21 to cover the new QR
branches: one fixture where invoice is empty but lnurl is provided (ensuring the
function returns a payload with &lightning=<lnurl>), and one fixture where sats
is zero/undefined (ensuring the returned payload omits the &amount= parameter).
Locate the encodeBip21 function and add tests that assert the exact returned
string for both cases (lnurl fallback and amount omission) so the unified QR
payload remain stable across changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bc2c7468-3e31-4fbc-ad14-6e169151b81e

📥 Commits

Reviewing files that changed from the base of the PR and between 122edc1 and d001ef9.

📒 Files selected for processing (5)
  • src/components/ExpandAddresses.tsx
  • src/hooks/useLnurlSession.ts
  • src/lib/bip21.ts
  • src/lib/constants.ts
  • src/screens/Wallet/Receive/QrCode.tsx

@arkanaai
Copy link
Copy Markdown

arkanaai bot commented Mar 26, 2026

Followup review (commit 6ab073e — review feedback hardening)

🐛 Import name mismatch — build will fail

src/hooks/useLnurlSession.ts line 2:

import { lnurlServerBaseUrl as rawLnurlServerUrl } from '../lib/constants'

But constants.ts exports lnurlServerUrl, not lnurlServerBaseUrl. This is a TS compile error — the LNURL feature is broken on this commit.

Fix: import { lnurlServerUrl as rawLnurlServerUrl } from '../lib/constants'

✅ Good changes in this commit

  • SSE JSON.parse wrapped in try/catch — prevents stream crash on malformed payloads
  • amountMsat validated as positive number before creating swap — blocks zero/negative amount attacks
  • Trailing slash stripped from LNURL server URL — prevents double-slash in fetch URLs
  • isAmountlessLnurl added to useEffect deps — correct React behavior
  • Type assertions added (as string, as string | undefined) — cleaner TypeScript

…idation

- postInvoice now checks response.ok and throws on failure
- Clear LNURL state on invoice request failure and in finally block
- Gate LNURL session on arkadeSwaps being initialized and connected
- Add validLnSwap check before creating reverse swap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@arkanaai
Copy link
Copy Markdown

arkanaai bot commented Mar 26, 2026

Followup review (commit f74ec68 — CodeRabbit feedback integration)

Good incremental improvements:

  1. postInvoice error propagation ✅ — Now throws on non-2xx instead of silently swallowing. This means the caller in the SSE loop will hit the catch block and set error state, which is correct.

  2. Guard conditions on isAmountlessLnurl ✅ — Adding connected && !!arkadeSwaps && !swapsInitError prevents opening an SSE session before the swap provider is ready. Eliminates a race where a payer could scan the LNURL before the wallet can actually create reverse swaps.

  3. validLnSwap(sats) check ✅ — Rejects amounts outside Boltz limits before hitting the API. Added to deps array too.

  4. Error state cleanupsetError + setLnurl("") on invoice request failure, and setLnurl("") in the finally block. Clean teardown.

One minor observation: on postInvoice failure (throw), the error propagates to the catch in the SSE loop which sets setError("Failed to fulfill payment request") and clears the LNURL. This means the payer gets no bolt11 and the session dies. That's probably the right UX (better to fail visibly than hang), but worth noting that the lnurl-server side may still have the session open — the payer could retry and get the same dead end. Not blocking, just a UX edge case to consider.

LGTM on the incremental changes.

The eventType variable was declared inside the while loop,
causing it to reset on each chunk read. When the event: and
data: lines arrive in separate chunks (common in browsers),
the data line was silently ignored. Move eventType outside
the loop so it persists across chunk boundaries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@arkanaai
Copy link
Copy Markdown

arkanaai bot commented Mar 26, 2026

Followup review — a9f56c7 (Fix SSE parsing: persist eventType across chunks)

Good fix. Moving eventType outside the while loop is correct — SSE event: and data: lines commonly arrive in separate TCP chunks in browsers, so eventType must persist across reader.read() calls. Without this, data: lines after a chunk boundary were silently dropped.

One observation: the same chunk-boundary issue could theoretically affect buffer if a line is split mid-way across chunks, but the existing buffer accumulation logic already handles that correctly (lines are split from buffer, and the incomplete trailing segment is kept). So this fix is complete as-is.

LGTM on this commit. ✅

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/screens/Wallet/Receive/QrCode.tsx (1)

58-61: ⚠️ Potential issue | 🟠 Major

Reject non-sat LNURL amounts instead of flooring them.

Line 60 silently rewrites the payer's requested amount: 1001 msat becomes a 1 sat reverse swap. Guard non-multiples of 1000 and only divide once the request is sat-aligned.

🧮 Proposed fix
     async (req: { amountMsat: number }) => {
-      const sats = Math.floor(req.amountMsat / 1000)
+      if (req.amountMsat % 1000 !== 0) {
+        throw new Error('Sub-sat LNURL amounts are unsupported')
+      }
+      const sats = req.amountMsat / 1000
       if (!validLnSwap(sats)) throw new Error('Amount outside Lightning receive limits')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/Wallet/Receive/QrCode.tsx` around lines 58 - 61,
handleInvoiceRequest currently floors req.amountMsat into sats which silently
accepts non-sat-aligned amounts; instead, first verify req.amountMsat is an
exact multiple of 1000 and throw an error if not (e.g., "Amount must be in whole
sats"), then compute sats = req.amountMsat / 1000 (no Math.floor) and pass that
to validLnSwap; update the logic in the handleInvoiceRequest callback to reject
non-multiples of 1000 before any further validation or processing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/hooks/useLnurlSession.ts`:
- Around line 42-49: Capture the current session id into a local const (e.g.,
const sessionId = sessionIdRef.current) before awaiting onInvoiceRequest in the
handler, update postInvoice to accept an AbortSignal and pass abort.signal into
fetch, and ensure any handler failure calls abort.abort() (the session's
AbortController) so the SSE stream and any inflight fetches are cancelled;
specifically modify useLnurlSession's onInvoiceRequest flow to use the captured
sessionId when calling postInvoice (instead of reading sessionIdRef.current
after awaits), add an abort parameter to postInvoice and pass it through to
fetch, and invoke abort.abort() on errors/cleanup to terminate the session
fetches and prevent stray invoice replies.

---

Duplicate comments:
In `@src/screens/Wallet/Receive/QrCode.tsx`:
- Around line 58-61: handleInvoiceRequest currently floors req.amountMsat into
sats which silently accepts non-sat-aligned amounts; instead, first verify
req.amountMsat is an exact multiple of 1000 and throw an error if not (e.g.,
"Amount must be in whole sats"), then compute sats = req.amountMsat / 1000 (no
Math.floor) and pass that to validLnSwap; update the logic in the
handleInvoiceRequest callback to reject non-multiples of 1000 before any further
validation or processing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bcd96db9-ad4e-4c15-851f-1504e2486549

📥 Commits

Reviewing files that changed from the base of the PR and between d001ef9 and a9f56c7.

📒 Files selected for processing (2)
  • src/hooks/useLnurlSession.ts
  • src/screens/Wallet/Receive/QrCode.tsx

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.

When the wallet can't create a swap (e.g. amount outside limits),
post { error: "reason" } back to the server so the payer gets an
immediate error instead of waiting for timeout. Remove validLnSwap
pre-check since the server enforces min/max via LNURL spec and
createReverseSwap will fail naturally with a descriptive error.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@arkanaai
Copy link
Copy Markdown

arkanaai bot commented Mar 26, 2026

🔍 Arkana PR Review — wallet#482

LNURL support for amountless Lightning receives

Clean feature addition. The SSE-based session management is well-structured, and the iterative hardening across commits (chunked SSE parsing fix, error propagation to server, amount validation) shows good evolution. A few observations:

Issues

1. Stale closure in handleInvoiceRequest (QrCode.tsx)
The callback captures recvInfo directly in its closure:

setRecvInfo({ ...recvInfo, satoshis: pendingSwap.response.onchainAmount })

If recvInfo changes between when the callback is memoized and when it executes (likely, since LNURL payments are async), you'll overwrite with stale state. Use the functional updater:

setRecvInfo(prev => ({ ...prev, satoshis: pendingSwap.response.onchainAmount }))

This also means recvInfo can be removed from the useCallback dependency array.

2. lnurlSession.error is never displayed to the user
If the SSE connection fails or disconnects, the user gets no feedback — the LNURL just silently disappears. Consider showing the error in the UI (e.g., a <WarningBox> or <ErrorMessage> component that's already imported).

Nits / Minor

3. Fire-and-forget waitAndClaim after unmount
In handleInvoiceRequest, the waitAndClaim promise continues after the component unmounts (abort controller only closes the SSE stream, not the claim). The .then() calls navigate() and setRecvInfo() on a potentially unmounted component. This won't crash React 18+ but is still untidy. Consider tracking a mounted ref or using the abort signal to skip post-claim navigation.

4. BIP21 lightning= parameter with LNURL
The encoding &lightning=${lnurl} is consistent with how wallets handle LNURL in BIP21 URIs (LUD-17 style). Just noting that not all wallets will recognize an LNURL in the lightning= parameter — some only expect bolt11 there. This is fine as a progressive enhancement since the QR also shows the LNURL in the expanded addresses.

Security

  • ✅ Amount validation (positive amountMsat check)
  • ✅ SSE JSON parsing hardened with try/catch
  • ✅ Server URL trailing slash normalized
  • ✅ Errors propagated back to lnurl-server (prevents payer timeout)
  • ✅ Session cleanup on unmount via AbortController
  • ⚠️ No auth on session endpoints — acceptable since the lnurl-server is operator-controlled and sessions are ephemeral, but worth documenting that the VITE_LNURL_SERVER_URL must be a trusted endpoint.

Verdict

Good to go with the stale closure fix (#1). The error display (#2) is a nice-to-have. Solid feature overall.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
src/screens/Wallet/Receive/QrCode.tsx (1)

59-61: ⚠️ Potential issue | 🟠 Major

Reject sub-satoshi LNURL amounts instead of rounding them down.

Math.floor(req.amountMsat / 1000) can turn a payer-selected amount like 1500 msat into a 1 sat invoice. That underbills the requested amount instead of rejecting an unsupported fractional-sat request up front.

🧮 Suggested fix
     async (req: { amountMsat: number }) => {
-      const sats = Math.floor(req.amountMsat / 1000)
+      if (req.amountMsat % 1000 !== 0) {
+        throw new Error('Sub-satoshi LNURL amounts are unsupported')
+      }
+      const sats = req.amountMsat / 1000
       const pendingSwap = await createReverseSwap(sats)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/screens/Wallet/Receive/QrCode.tsx` around lines 59 - 61, The handler that
converts req.amountMsat to sats currently floors fractional-sat values; instead
validate req.amountMsat is a whole-satoshi amount before conversion: check
req.amountMsat % 1000 === 0 and if not reject/throw an error (e.g., return a 400
or throw new Error) rather than calling createReverseSwap; only compute const
sats = req.amountMsat / 1000 and call createReverseSwap(sats) when the msat
value is exactly divisible by 1000.
src/hooks/useLnurlSession.ts (1)

42-63: ⚠️ Potential issue | 🟠 Major

Keep invoice/error replies bound to the session that requested them.

After await onInvoiceRequestRef.current(...), this code rereads sessionIdRef.current, so a request that started under session A can reply into session B if the hook is disabled/re-enabled in place. The reply POSTs also omit abort.signal, and postError never inspects response.ok, so stale or failed callbacks can leave a dead LNURL advertised. Capture the current sessionId before the await, pass abort.signal into both POSTs, and fail the session when the error callback cannot be delivered.

Also applies to: 122-135

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useLnurlSession.ts` around lines 42 - 63, Capture the sessionId
from sessionIdRef.current into a local const before awaiting
onInvoiceRequestRef.current so the reply is tied to the session that initiated
the request; then pass the current abort.signal into both fetch calls in
postInvoice and postError, and in postError inspect response.ok and throw an
error (or otherwise mark the session failed) when the POST fails so the session
is aborted/failed if the error callback cannot be delivered; update references
to postInvoice, postError, onInvoiceRequestRef.current, and sessionIdRef.current
accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/hooks/useLnurlSession.ts`:
- Around line 116-120: The code currently logs invalid amountMsat and drops the
invoice_request; instead, when Number(data.amountMsat) is falsy or <= 0,
construct and send an immediate error reply to the requester (using your
existing response/emit helper used elsewhere in this hook) rather than just
consoleError, then clear eventType and continue; specifically, in the branch
that checks amountMsat (referencing amountMsat, consoleError, eventType) call
the same reply routine you use for other errors (e.g.,
sendResponse/sendError/emitError in this hook) with a clear error string (e.g.,
"invalid_amount" / "amount must be > 0") so the payer receives an immediate
error before continuing.

---

Duplicate comments:
In `@src/hooks/useLnurlSession.ts`:
- Around line 42-63: Capture the sessionId from sessionIdRef.current into a
local const before awaiting onInvoiceRequestRef.current so the reply is tied to
the session that initiated the request; then pass the current abort.signal into
both fetch calls in postInvoice and postError, and in postError inspect
response.ok and throw an error (or otherwise mark the session failed) when the
POST fails so the session is aborted/failed if the error callback cannot be
delivered; update references to postInvoice, postError,
onInvoiceRequestRef.current, and sessionIdRef.current accordingly.

In `@src/screens/Wallet/Receive/QrCode.tsx`:
- Around line 59-61: The handler that converts req.amountMsat to sats currently
floors fractional-sat values; instead validate req.amountMsat is a whole-satoshi
amount before conversion: check req.amountMsat % 1000 === 0 and if not
reject/throw an error (e.g., return a 400 or throw new Error) rather than
calling createReverseSwap; only compute const sats = req.amountMsat / 1000 and
call createReverseSwap(sats) when the msat value is exactly divisible by 1000.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 050b2923-e80f-49e2-82ef-f66d00851cf6

📥 Commits

Reviewing files that changed from the base of the PR and between a9f56c7 and e4f6925.

📒 Files selected for processing (2)
  • src/hooks/useLnurlSession.ts
  • src/screens/Wallet/Receive/QrCode.tsx

Store the token from session_created SSE event and include it
as Authorization: Bearer header on all invoice POST requests.
Prevents unauthorized parties from submitting fake invoices
even if they discover the session ID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (2)
src/hooks/useLnurlSession.ts (2)

131-151: ⚠️ Potential issue | 🟠 Major

Capture session id before awaiting invoice creation to prevent cross-session replies.

At Line 139, reply generation is awaited before reading sessionIdRef.current (Line 143/149). If a new LNURL session starts meanwhile, the old request can be posted to the new session id. Capture sessionId once at the start of the invoice_request branch and use it for both success and error posts; also thread abort.signal into reply POSTs so teardown cancels in-flight callbacks.

🔧 Proposed hardening
-  const postInvoice = useCallback(
-    async (sessionId: string, pr: string) => {
+  const postInvoice = useCallback(
+    async (sessionId: string, pr: string, signal: AbortSignal) => {
       const response = await fetch(`${lnurlServerBaseUrl}/lnurl/session/${sessionId}/invoice`, {
         method: 'POST',
+        signal,
         headers: authHeaders(),
         body: JSON.stringify({ pr }),
       })
@@
-  const postError = useCallback(
-    async (sessionId: string, reason: string) => {
+  const postError = useCallback(
+    async (sessionId: string, reason: string, signal: AbortSignal) => {
       try {
         await fetch(`${lnurlServerBaseUrl}/lnurl/session/${sessionId}/invoice`, {
           method: 'POST',
+          signal,
           headers: authHeaders(),
           body: JSON.stringify({ error: reason }),
         })
@@
               } else if (eventType === 'invoice_request') {
+                const sessionId = sessionIdRef.current
+                if (!sessionId) {
+                  eventType = ''
+                  continue
+                }
                 const amountMsat = Number(data.amountMsat)
                 if (!amountMsat || amountMsat <= 0) {
@@
                 try {
                   const pr = await onInvoiceRequestRef.current({
                     amountMsat,
                     comment: data.comment as string | undefined,
                   })
-                  if (sessionIdRef.current) {
-                    await postInvoice(sessionIdRef.current, pr)
-                  }
+                  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')
-                  if (sessionIdRef.current) {
-                    await postError(sessionIdRef.current, reason)
-                  }
+                  await postError(sessionId, reason, abort.signal)
                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useLnurlSession.ts` around lines 131 - 151, In the invoice_request
branch of useLnurlSession (around the onInvoiceRequestRef.current call), capture
sessionIdRef.current into a local const (e.g. const sessionId =
sessionIdRef.current) before awaiting onInvoiceRequestRef.current so subsequent
awaits can't race into a new session; then use that sessionId for both
postInvoice and postError calls instead of reading sessionIdRef.current again,
and pass the abort.signal into postInvoice and postError so in-flight reply
POSTs are cancelled when teardown occurs.

132-136: ⚠️ Potential issue | 🟡 Minor

Reply on invalid amountMsat instead of silently dropping the request.

At Line 133-136, malformed requests are only logged and ignored, which leaves payer-side flow waiting for timeout. Send an immediate error response for invalid amounts (using the same invoice endpoint error payload path) before continuing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useLnurlSession.ts` around lines 132 - 136, The code currently logs
invalid amountMsat and skips the event, leaving the payer waiting; instead, when
Number(data.amountMsat) is falsy or <= 0 you should build and send the same
invoice-endpoint error payload back to the requester before continuing: detect
the invalid amountMsat (the check around amountMsat in useLnurlSession.ts),
construct the standard invoice error response used elsewhere in this hook (reuse
the same invoice endpoint error payload structure/path used for successful
errors), send it via the same send/response mechanism the hook uses for invoice
responses (instead of only calling consoleError), then clear eventType and
continue as before so the payer receives an immediate error. Ensure you
reference amountMsat, consoleError, and the invoice response sender used in this
file when implementing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/hooks/useLnurlSession.ts`:
- Around line 131-151: In the invoice_request branch of useLnurlSession (around
the onInvoiceRequestRef.current call), capture sessionIdRef.current into a local
const (e.g. const sessionId = sessionIdRef.current) before awaiting
onInvoiceRequestRef.current so subsequent awaits can't race into a new session;
then use that sessionId for both postInvoice and postError calls instead of
reading sessionIdRef.current again, and pass the abort.signal into postInvoice
and postError so in-flight reply POSTs are cancelled when teardown occurs.
- Around line 132-136: The code currently logs invalid amountMsat and skips the
event, leaving the payer waiting; instead, when Number(data.amountMsat) is falsy
or <= 0 you should build and send the same invoice-endpoint error payload back
to the requester before continuing: detect the invalid amountMsat (the check
around amountMsat in useLnurlSession.ts), construct the standard invoice error
response used elsewhere in this hook (reuse the same invoice endpoint error
payload structure/path used for successful errors), send it via the same
send/response mechanism the hook uses for invoice responses (instead of only
calling consoleError), then clear eventType and continue as before so the payer
receives an immediate error. Ensure you reference amountMsat, consoleError, and
the invoice response sender used in this file when implementing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ba3f4c04-044c-4b95-9bc0-cfdda3880f05

📥 Commits

Reviewing files that changed from the base of the PR and between e4f6925 and 36a866b.

📒 Files selected for processing (1)
  • src/hooks/useLnurlSession.ts

- Capture sessionId in local const before awaiting to prevent
  race between concurrent events
- Pass abort signal to postInvoice/postError so in-flight POSTs
  cancel on teardown
- Send error back to server on invalid amountMsat instead of
  silently dropping (which left payer waiting until timeout)
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/hooks/useLnurlSession.ts (1)

135-138: ⚠️ Potential issue | 🟡 Minor

Avoid break here; it can drop remaining buffered SSE lines.

When sessionId is missing, break exits the current line-processing loop and skips any remaining lines in the chunk. Prefer resetting eventType and continue.

💡 Suggested fix
               } else if (eventType === 'invoice_request') {
                 const sessionId = sessionIdRef.current
-                if (!sessionId) break
+                if (!sessionId) {
+                  eventType = ''
+                  continue
+                }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/useLnurlSession.ts` around lines 135 - 138, In useLnurlSession's
SSE line-processing block (the branch handling eventType === 'invoice_request'),
don't use break when sessionIdRef.current is missing because that exits the
per-chunk loop and drops remaining buffered lines; instead reset eventType
(e.g., set eventType = null or undefined) and continue the loop so the remaining
lines in the chunk are still processed; update the invoice_request branch that
references sessionIdRef and eventType to perform this reset+continue flow rather
than a break.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/hooks/useLnurlSession.ts`:
- Around line 66-75: The postError function currently ignores non-2xx HTTP
responses; update postError (in useLnurlSession.ts) to mirror postInvoice
behavior by checking the fetch response.ok after awaiting
fetch(`${lnurlServerBaseUrl}/lnurl/session/${sessionId}/invoice`, ...) and
treating non-ok responses as failures (throw or return an error/false and
include response status/text in the log/error). Ensure you still catch network
exceptions in the existing catch block and surface server-side errors the same
way postInvoice does so callers can detect and handle rejected error
notifications.

---

Duplicate comments:
In `@src/hooks/useLnurlSession.ts`:
- Around line 135-138: In useLnurlSession's SSE line-processing block (the
branch handling eventType === 'invoice_request'), don't use break when
sessionIdRef.current is missing because that exits the per-chunk loop and drops
remaining buffered lines; instead reset eventType (e.g., set eventType = null or
undefined) and continue the loop so the remaining lines in the chunk are still
processed; update the invoice_request branch that references sessionIdRef and
eventType to perform this reset+continue flow rather than a break.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 417586e1-18a5-483b-8d74-ec0d0ec56633

📥 Commits

Reviewing files that changed from the base of the PR and between 36a866b and 7c59f90.

📒 Files selected for processing (1)
  • src/hooks/useLnurlSession.ts

postError silently ignored non-2xx HTTP responses, unlike postInvoice
which throws on failures. Now logs the status and response text when
the server rejects the error notification.
@arkanaai
Copy link
Copy Markdown

arkanaai bot commented Mar 30, 2026

Followup review (commit 2a43831)

Good fix — postError now checks response.ok and logs the response body on failure instead of silently swallowing server-side errors. Clean improvement to error observability.

No protocol or security concerns.

@arkanaai
Copy link
Copy Markdown

arkanaai bot commented Mar 30, 2026

🔍 Arkana PR Review — wallet#482

Add LNURL support for amountless Lightning receives

Clean feature implementation overall. The SSE session management pattern is solid and the BIP21 integration is correct. A few items worth addressing:


⚠️ Issues

1. No SSE reconnection logic (src/hooks/useLnurlSession.ts)
If the SSE connection drops mid-session (network hiccup, server restart), the hook silently clears state in the finally block — no LNURL, no error shown to the user. The user sees the QR revert without explanation.

Consider adding retry logic with exponential backoff, or at minimum set error state so the UI can inform the user:

} catch (err) {
  if (!abort.signal.aborted) {
    consoleError(err, "LNURL session error")
    setError("LNURL session disconnected")
    // Optionally: retry after delay
  }
}

Currently setError("LNURL session disconnected") is set but then immediately cleared by finallysetLnurl(""), and no UI displays the error.

2. Stale closure / unmounted-component writes in handleInvoiceRequest (src/screens/Wallet/Receive/QrCode.tsx, ~L65-80)
arkadeSwaps.waitAndClaim(pendingSwap) is fire-and-forget — it continues after the component unmounts. The .then() calls navigate(), setRecvInfo(), and notifyPaymentReceived() on a potentially unmounted component. This can cause React warnings and unexpected navigation.

Suggestion: track a mounted/abort ref and guard the .then() callback, or move claim tracking to a provider-level effect that persists across navigation.

3. Race condition with concurrent invoice requests (src/screens/Wallet/Receive/QrCode.tsx, L65)
If a payer triggers multiple invoice requests in quick succession (e.g., scanning twice), multiple reverse swaps could be created concurrently. Consider adding a lock/debounce — e.g., a pendingRef that rejects subsequent requests while one is in flight:

if (pendingRef.current) throw new Error("Invoice creation already in progress")

4. No amount bounds validation (src/hooks/useLnurlSession.ts, L145)
The hook validates amountMsat > 0 but doesn't enforce min/max bounds. If the lnurl-server's LNURL-pay metadata advertises min/max, those should also be enforced wallet-side before attempting the reverse swap. A very large amount could fail the swap and waste time; a dust-level amount could create uneconomical swaps.


💡 Nits (non-blocking)

  • encodeBip21 parameter growth (src/lib/bip21.ts): With 5 positional parameters now, consider switching to an options object for readability: encodeBip21({ address, arkAddress, invoice, sats, lnurl }).
  • consoleError import missing in QrCode.tsx: The catch block at line ~76 references consoleError — confirm it's imported (it's used elsewhere in the file so probably fine, just verify).
  • Dependency array in useEffect (L182): isAmountlessLnurl is derived from multiple values — if any of its constituent values change independently, the effect re-runs and reconstructs the BIP21. This is correct behavior but worth noting in a comment for future maintainers.

✅ Looks Good

  • SSE parsing with proper buffering for incomplete chunks
  • Bearer token management via ref (avoids re-renders)
  • Clean abort controller cleanup on unmount
  • BIP21 encoding correctly omits amount= when sats is 0
  • LNURL placed in lightning= parameter per BIP21+LNURL convention
  • Feature-gated behind VITE_LNURL_SERVER_URL — no impact when unconfigured
  • Error responses posted back to lnurl-server so payers get proper feedback

Reviewed by Arkana 🔍

@Kukks Kukks merged commit fa406a3 into master Mar 31, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant