Skip to content

fix: extract LNURL session into app-level provider with reusable sessions#559

Open
Kukks wants to merge 8 commits into
masterfrom
fix/lnurl-lifecycle
Open

fix: extract LNURL session into app-level provider with reusable sessions#559
Kukks wants to merge 8 commits into
masterfrom
fix/lnurl-lifecycle

Conversation

@Kukks
Copy link
Copy Markdown
Contributor

@Kukks Kukks commented Apr 22, 2026

Summary

  • Introduces LnurlProvider that manages the LNURL session lifecycle at the wallet level — the session starts automatically when the wallet and Boltz swaps are ready
  • The LNURL is always active while the wallet is open (if Boltz is enabled), not just on the receive screen
  • Reusable LNURL sessions: derives a deterministic token using HMAC-SHA256 with the wallet's private key, sends it to the server which derives the sessionId — same wallet always gets the same LNURL
  • Invoice requests are handled entirely in the provider: create reverse swap, return invoice, claim in background, notify on completion
  • Fixes Windows lint script (single-quoted globs don't expand on Windows)

Problem

Three issues fixed:

  1. Session lifecycle: The LNURL session was managed inside a useEffect on the receive screen. When the user navigated away, the SSE connection was killed and the payer's LNURL became invalid.

  2. Non-deterministic LNURL: Every session got a random ID, so the LNURL changed every time. Payers couldn't reuse a wallet's LNURL across sessions.

  3. Session only active on receive screen: The LNURL session only started when the user was on the amountless receive screen. Now it's always active when the wallet is open.

Architecture

LnurlProvider (app-level, in index.tsx)
  └─ consumes WalletContext, SwapsContext, NotificationsContext
  └─ starts session when identity + Boltz ready, stops when they aren't
  └─ handles invoice requests: createReverseSwap → return invoice → claim
      │
LnurlContext (consumed by any component)
  └─ exposes: lnurl, active, error (read-only)
      │
QrCode.tsx (receive screen)
  └─ reads lnurl/active from context to display QR
  └─ existing VTXO_UPDATE listener handles navigation to success screen

Credential derivation

deriveLnurlCredentials(privateKeyHex) in src/lib/lnurl.ts:

  • token = hex-encoded HMAC-SHA256(privateKey, "lnurl-session") — deterministic by construction
  • Wallet sends only { token } to the server
  • Server derives sessionId = SHA-256(tokenBytes).slice(0, 32) — eliminates mismatched pairs and makes session hijack impossible by construction
  • Uses @noble/hashes (hmac + sha256), synchronous

Companion PR

Test plan

  • Open wallet — LNURL session starts automatically (check network tab for SSE stream)
  • Navigate to receive screen — LNURL QR is displayed
  • Navigate away and back — same LNURL, session still active
  • Close and reopen wallet — same LNURL appears (deterministic credentials)
  • Have a payer send to LNURL while NOT on receive screen — swap is created, payment completes, notification shown
  • Have a payer send while ON receive screen — same as above, plus navigates to success
  • Disable Boltz / disconnect — LNURL session stops
  • Lock wallet — LNURL session stops
  • Unit tests pass

Summary by CodeRabbit

  • Refactor

    • Reorganized Lightning Network LNURL session management to use a centralized context provider for improved state management across the app.
  • Chores

    • Updated build script syntax for consistency.

Move the SSE connection lifecycle from the useLnurlSession hook into
a new LnurlProvider so the session survives component unmount. When
the user navigates away from the receive screen, the LNURL remains
active and can still serve invoice requests from payers.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 22, 2026

Walkthrough

LNURL session handling was moved out of a removed useLnurlSession hook into a mounted LnurlProvider/context; deriveLnurlCredentials was added to produce a session token. Receive QrCode now reads LNURL state from context. Minor package.json lint quoting and regtest submodule pointer updates included.

Changes

LNURL session migration

Layer / File(s) Summary
Data Shape
src/lib/lnurl.ts
Adds LnurlSessionCredentials (token: string) and deriveLnurlCredentials(privateKeyHex) which HMAC-SHA256s "lnurl-session" with the decoded key and returns { token }.
Provider API
src/providers/lnurl.tsx
LnurlContextProps.error made explicit as `string
Hook Removal
src/hooks/useLnurlSession.ts
Entire useLnurlSession hook deleted (SSE streaming, session lifecycle, invoice_request handling removed).
App Integration
src/index.tsx
Imports and mounts LnurlProvider so LimitsProvider (and nested providers) are children of LnurlProvider.
Usage Update
src/screens/Wallet/Receive/QrCode.tsx
Component now reads lnurlSession from LnurlContext (lnurl, active, error); local invoice-request handler and prior hook usage removed.

Build & Submodule

Layer / File(s) Summary
Package Configuration
package.json
lint and lint:fix scripts changed their glob argument quoting from single to double quotes.
Submodule Version
regtest
regtest submodule pointer advanced to commit 9e30685a694a98ec4dff971dedbf5520cbe30787.

Sequence Diagram

sequenceDiagram
    participant QR as QrCode Component
    participant Context as LnurlProvider/Context
    participant Server as LNURL Server
    participant Swaps as Swaps Service

    QR->>Context: read { lnurl, active, error }
    Context->>Server: POST /lnurl/session (optional credentials/token)
    Server-->>Context: SSE -> session_created (sessionId, lnurl, token?)
    Context->>Context: set session state (lnurl, active, token)
    Server-->>Context: SSE -> invoice_request { amountMsat,... }
    Context->>Swaps: createReverseSwap(amount)
    Swaps-->>Context: returns invoice (pr)
    Context->>Server: POST /lnurl/session/:sessionId/invoice { pr } or { error }
    Server-->>Context: 200 OK
    QR->>Context: unmount/disable -> provider-managed cleanup
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

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

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: extracting LNURL session management into an app-level provider with reusable sessions, which aligns with the significant refactoring across multiple files and the core objective of the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/lnurl-lifecycle

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 Apr 22, 2026

Deploying wallet-bitcoin with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7739d52
Status: ✅  Deploy successful!
Preview URL: https://1d2ed1c2.wallet-bitcoin.pages.dev
Branch Preview URL: https://fix-lnurl-lifecycle.wallet-bitcoin.pages.dev

View logs

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

cloudflare-workers-and-pages Bot commented Apr 22, 2026

Deploying wallet-mutinynet with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7739d52
Status: ✅  Deploy successful!
Preview URL: https://96d5fd79.arkade-wallet.pages.dev
Branch Preview URL: https://fix-lnurl-lifecycle.arkade-wallet.pages.dev

View logs

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

cloudflare-workers-and-pages Bot commented Apr 22, 2026

Deploying tmp-boltz-upstream-mainnet-arkade-wallet with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7739d52
Status: ✅  Deploy successful!
Preview URL: https://cc2e41d8.tmp-boltz-upstream-mainnet-arkade-wallet.pages.dev
Branch Preview URL: https://fix-lnurl-lifecycle.tmp-boltz-upstream-mainnet-arkade-wallet.pages.dev

View logs

@Kukks
Copy link
Copy Markdown
Contributor Author

Kukks commented Apr 23, 2026

@pietro909 maybe we should consider moving this code to the swaps package and also offering within service worker?

@bordalix bordalix mentioned this pull request Apr 23, 2026
@Kukks Kukks requested a review from bordalix April 29, 2026 12:50
Kukks added 2 commits May 5, 2026 19:12
Merge master and extend LNURL provider to support deterministic sessions.
When a wallet address is available, derive sessionId and token via SHA-256
so the same wallet always gets the same LNURL. The server accepts optional
client-provided credentials in POST /lnurl/session body.
@Kukks Kukks changed the title fix: extract LNURL session into app-level provider fix: extract LNURL session into app-level provider with reusable sessions May 5, 2026
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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@package.json`:
- Around line 45-46: The package.json scripts are inconsistent: "lint" uses
double quotes for the glob while "lint:fix" uses single quotes which breaks on
Windows; update the "lint:fix" script to use the same double-quoted glob pattern
as "lint" so both scripts use eslint "src/**/*.{ts,tsx,js,jsx}" and behave
consistently on Windows (modify the "lint:fix" script entry in package.json
accordingly).

In `@src/lib/lnurl.ts`:
- Around line 107-114: deriveLnurlCredentials currently derives both sessionId
and token deterministically from walletAddress (in deriveLnurlCredentials),
which makes the bearer token guessable by anyone who knows the public receive
address; change this so that sessionId can remain a deterministic/predictable
identifier but the token is generated unpredictably and issued/stored by the
server (or locally persisted securely), e.g., generate a cryptographically
random token (or HMAC with a server-only secret) for token instead of
sha256(walletAddress), return the stable sessionId if needed but replace the
token generation in deriveLnurlCredentials with a call/flow that obtains a
server-issued random token or creates a secure random token and persists it so
Authorization: Bearer cannot be recomputed from a public address; update any
callers (e.g., providers/lnurl.tsx and QrCode.tsx) to use the new
token-fetch/issue flow.
🪄 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: 58e8e490-ac66-4550-86fe-c124ab04d5c4

📥 Commits

Reviewing files that changed from the base of the PR and between 7192bdf and f4900e2.

📒 Files selected for processing (7)
  • package.json
  • regtest
  • src/hooks/useLnurlSession.ts
  • src/index.tsx
  • src/lib/lnurl.ts
  • src/providers/lnurl.tsx
  • src/screens/Wallet/Receive/QrCode.tsx

Comment thread package.json Outdated
Comment thread src/lib/lnurl.ts Outdated
sessionId is public (embedded in the LNURL QR), so deriving the token
from the sessionId would let anyone who scans the QR compute the auth
token. Instead, derive token from wallet address, then sessionId from
token — the hash is one-way so the public sessionId doesn't leak the
token.
Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

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

🔴 Arkana Code Review — wallet#559

CRITICAL: Session credentials derived from public data — enables payment theft

src/lib/lnurl.ts:106-111deriveLnurlCredentials() derives the session token and sessionId solely from the wallet's offchain address (walletAddress), which is public information (displayed as a QR code, embedded in BIP21 URIs, shared with payers).

const token = hex.encode(sha256(encoder.encode('lnurl-session:' + walletAddress)))
const sessionId = hex.encode(sha256(encoder.encode(token))).slice(0, 32)

Attack scenario:

  1. Attacker obtains the victim's public Ark address (from a payment request, LNURL callback URL, etc.)
  2. Attacker computes token = SHA-256("lnurl-session:" + arkAddress) and sessionId = SHA-256(token)[0:32]
  3. Attacker calls POST /lnurl/session with {sessionId, token}
  4. Per the companion PR (ArkLabsHQ/lnurl-server#2), the server destroys the existing session and creates a new one for the attacker's SSE stream
  5. Attacker now receives all invoice_request events and can post their own bolt11 invoices
  6. Payer's funds are redirected to the attacker

Even without the session-takeover vector, the token is computable by anyone who knows the address, so Bearer auth provides zero protection.

Fix: Derive credentials from a wallet secret (private key, seed, HMAC key), not the public address. E.g. HMAC-SHA256(walletPrivateKey, "lnurl-session-credentials"). This ensures only the wallet owner can compute valid credentials.


HIGH: PR description does not match implementation

The PR description claims:

  • sessionId = first 32 hex chars of SHA-256("lnurl-session-id:" + walletAddress)
  • token = full hex of SHA-256("lnurl-session-token:" + walletAddress)

Actual code:

token  = SHA-256("lnurl-session:" + walletAddress)     // single prefix, not "lnurl-session-token:"
sessionId = SHA-256(token).slice(0, 32)                 // hash of token, not "lnurl-session-id:" + address

The described "domain-separated inputs" don't exist. The sessionId is derived from the token (chained hash), not independently keyed. Update the description to match the code, or (better) fix the code to use the described domain separation.


MEDIUM: Missing React effect dependencies in useLnurlSession

src/hooks/useLnurlSession.ts:38-45

useEffect(() => {
  if (enabled && !startedRef.current && !active) {
    startedRef.current = true
    startSession(onInvoiceRequest, credentials)
  } else if (!enabled && startedRef.current) {
    startedRef.current = false
    stopSession()
  }
}, [enabled])  // ← missing: credentials, active, startSession, stopSession
  • If walletAddress changes (e.g. wallet switch), credentials changes but the effect won't re-run → stale session with old credentials.
  • active is read but not in deps → could use stale value from closure.
  • startSession/stopSession are stable (useCallback), but should still be listed per exhaustive-deps.

At minimum, add credentials and active to the dependency array.


LOW: lint:fix script not updated

package.json:45 — The lint script was changed from single to double quotes for Windows compatibility, but lint:fix still uses single quotes:

"lint:fix": "eslint 'src/**/*.{ts,tsx,js,jsx}' --fix",

Should be consistent.


NOTE: No SSE reconnection on disconnect

Not a regression (old code had the same issue), but worth noting: if the SSE connection drops (network blip, server restart), the session dies silently. With deterministic credentials, auto-reconnect would be trivial and expected by users. Consider adding it.


Summary

The credential derivation from public data is a blocking security issue that enables payment theft. Everything else is secondary. Please derive credentials from a secret the wallet controls before merging.

⚠️ Protocol-critical: This PR touches payment flow (LNURL → reverse swap → VTXO claim). Requires human review even after the security issue is addressed.

Sign the message "lnurl-session" with the wallet's private key
(Schnorr, deterministic via RFC 6979) and use the signature as
the token. The sessionId is hash(token). This makes the token
a true secret — knowing the public address is no longer enough
to compute it.
Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

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

🔴 Arkana Code Review — wallet#559 (follow-up on new commits)

Previous review: credentials derived from public address → payment theft. The new commit (refactor: derive LNURL credentials from identity signature) addresses this by using identity.signMessage. The credentials now require the private key. Security issue resolved.

However, the fix introduces a new functional bug:


HIGH: Schnorr signatures are non-deterministic — "reusable LNURL" feature is broken

src/lib/lnurl.ts (new lines 106-111)

const sig = await identity.signMessage(
  new TextEncoder().encode('lnurl-session'), 'schnorr'
)
const token = hex.encode(sig)
const sessionId = hex.encode(sha256(new TextEncoder().encode(token))).slice(0, 32)

identity.signMessage calls schnorr.signAsync(message, this.key) (in SingleKey), which defaults auxRand = randomBytes(32) per BIP340. This means:

  • Same key + same message → different signature every time
  • Every call to deriveLnurlCredentials produces different token/sessionId
  • On app restart, page refresh, or React remount, new credentials are derived
  • The old LNURL session on the server is orphaned; a new one is created
  • Payers cannot bookmark or reuse the LNURL — contradicts the stated goal

The Identity interface doesn't expose an auxRand parameter, so you can't just pass new Uint8Array(32) to make it deterministic.

Fix options (in preference order):

  1. HMAC-SHA256 with the private keyderiveLnurlCredentials should accept the raw key material and use HMAC-SHA256(privkey, "lnurl-session-token"). Deterministic, doesn't leak the key, and doesn't require interface changes. You already have @noble/hashes in the dep tree.
  2. Add a signDeterministic option to the Identity interface that passes auxRand = new Uint8Array(32). This couples the SDK to an implementation detail of BIP340 — less clean.
  3. Persist the credentials to storage on first derivation and reuse them. Works around non-determinism but adds statefulness.

MEDIUM: Missing effect dependencies (partially fixed)

src/hooks/useLnurlSession.ts:37-44credentials was added to deps (good), but active, startSession, stopSession are still missing:

useEffect(() => {
  if (enabled && !startedRef.current && !active && (credentials || !identity)) {
    startedRef.current = true
    startSession(onInvoiceRequest, credentials)
  } else if (!enabled && startedRef.current) {
    startedRef.current = false
    stopSession()
  }
}, [enabled, credentials])  // ← active, startSession, stopSession missing

Practical impact: if the user switches wallets while a session is active, identity changes → credentials changes → effect re-runs, but startedRef.current is already true so the branch is skipped. The session continues with old credentials. Add logic to restart on credential change:

useEffect(() => {
  if (enabled && credentials) {
    startedRef.current = true
    startSession(onInvoiceRequest, credentials)  // startSession already calls stopSession internally
  } else if (!enabled && startedRef.current) {
    startedRef.current = false
    stopSession()
  }
}, [enabled, credentials, startSession, stopSession])

LOW: lint:fix still uses single quotes (from previous review, not addressed)

package.json:45lint was fixed for Windows but lint:fix wasn't.


Summary

The security issue from the previous review is properly fixed — credentials now require the private key. However, the Schnorr non-determinism means the "reusable/bookmarkable LNURL" feature doesn't actually work. Every app restart generates a new LNURL. This is the blocking issue.

⚠️ Protocol-critical: touches payment flow (LNURL → reverse swap). Requires human review.

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

🧹 Nitpick comments (2)
src/hooks/useLnurlSession.ts (1)

26-32: 💤 Low value

Race on rapid identity changes.

deriveLnurlCredentials(identity).then(setCredentials) has no cancellation, so if identity changes from A → B before A's promise resolves (e.g., wallet unlock/relock, fast HMR), A's credentials can land after B's and clobber the newer value. In the current wallet flow identity is effectively stable, so this is mostly a future-proofing nit, but a simple cancelled flag in the effect cleanup would close it.

Suggested guard
   useEffect(() => {
     if (!identity) {
       setCredentials(undefined)
       return
     }
-    deriveLnurlCredentials(identity).then(setCredentials)
+    let cancelled = false
+    deriveLnurlCredentials(identity).then((c) => {
+      if (!cancelled) setCredentials(c)
+    })
+    return () => {
+      cancelled = true
+    }
   }, [identity])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useLnurlSession.ts` around lines 26 - 32, The effect in
useLnurlSession can set stale credentials when identity changes rapidly; modify
the useEffect that calls deriveLnurlCredentials(identity).then(setCredentials)
to use a local "cancelled" (or "active") flag captured by the effect, set it to
true in the cleanup, and only call setCredentials if the flag indicates the
result is still active; also ensure you still call setCredentials(undefined)
immediately when identity is falsy. Reference the useEffect,
deriveLnurlCredentials, setCredentials and identity identifiers when locating
and implementing this guard.
src/lib/lnurl.ts (1)

109-111: 💤 Low value

Minor: hash the raw signature bytes, not the hex-encoded token.

sessionId currently hashes the hex string of the signature, which doubles the SHA-256 input size for no security benefit. Hashing sig directly is equivalent in collision resistance and avoids the redundant encode→encode round-trip. While here, you can also reuse a single TextEncoder.

Proposed simplification
 export const deriveLnurlCredentials = async (identity: Identity): Promise<LnurlSessionCredentials> => {
-  const sig = await identity.signMessage(new TextEncoder().encode('lnurl-session'), 'schnorr')
+  const sig = await identity.signMessage(new TextEncoder().encode('lnurl-session'), 'schnorr')
   const token = hex.encode(sig)
-  const sessionId = hex.encode(sha256(new TextEncoder().encode(token))).slice(0, 32)
+  const sessionId = hex.encode(sha256(sig)).slice(0, 32)
   return { sessionId, token }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/lnurl.ts` around lines 109 - 111, sessionId is computed by hashing
the hex-encoded signature string which is redundant and expands the input;
instead hash the raw signature bytes and reuse a single TextEncoder for any text
encoding needs. Update the block where sig, token and sessionId are created so
that you still produce token = hex.encode(sig) if needed, but compute sessionId
= hex.encode(sha256(sig)).slice(0, 32) (and create one TextEncoder instance if
other text encoding is required elsewhere) to avoid the extra encode→encode
round-trip; adjust references to sig, token and sessionId accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/hooks/useLnurlSession.ts`:
- Around line 38-46: The effect that manages the session (the useEffect using
startedRef, enabled, credentials, identity, active, onInvoiceRequest,
startSession, stopSession) must include all referenced values in its dependency
array and change its gating so it only starts when valid credentials are
available if an identity exists; update the logic to (a) list [enabled,
credentials, identity, active, onInvoiceRequest, startSession, stopSession] as
deps (or wrap callbacks in useCallback/memoize them), (b) when enabled is true
and either identity is null (anonymous) or credentials exist, startSession; but
if a session is already started with different credentials detect that (compare
stored/previous credentials) and call stopSession then startSession to rotate,
and (c) when enabled becomes false or credentials are removed, stopSession and
clear startedRef; ensure startedRef is reset on stop to allow restart when
credentials later arrive.

In `@src/lib/lnurl.ts`:
- Around line 108-113: deriveLnurlCredentials currently calls
identity.signMessage(..., 'schnorr') which may produce non-deterministic
signatures; either make the signature deterministic by passing a zeroed aux_rand
(if the SDK/identity.signMessage supports providing auxiliary randomness) when
calling identity.signMessage in deriveLnurlCredentials, or change
deriveLnurlCredentials to persist and reuse the first computed {sessionId,
token} (e.g., in localStorage/sessionStorage) so subsequent calls return the
same credentials for the same identity; update the deriveLnurlCredentials
function and any related initialization logic (useLnurlSession) to read/write
that stored credential instead of re-signing every time.

---

Nitpick comments:
In `@src/hooks/useLnurlSession.ts`:
- Around line 26-32: The effect in useLnurlSession can set stale credentials
when identity changes rapidly; modify the useEffect that calls
deriveLnurlCredentials(identity).then(setCredentials) to use a local "cancelled"
(or "active") flag captured by the effect, set it to true in the cleanup, and
only call setCredentials if the flag indicates the result is still active; also
ensure you still call setCredentials(undefined) immediately when identity is
falsy. Reference the useEffect, deriveLnurlCredentials, setCredentials and
identity identifiers when locating and implementing this guard.

In `@src/lib/lnurl.ts`:
- Around line 109-111: sessionId is computed by hashing the hex-encoded
signature string which is redundant and expands the input; instead hash the raw
signature bytes and reuse a single TextEncoder for any text encoding needs.
Update the block where sig, token and sessionId are created so that you still
produce token = hex.encode(sig) if needed, but compute sessionId =
hex.encode(sha256(sig)).slice(0, 32) (and create one TextEncoder instance if
other text encoding is required elsewhere) to avoid the extra encode→encode
round-trip; adjust references to sig, token and sessionId accordingly.
🪄 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: 374e731a-5138-4a59-bc2b-0a50a7b8992b

📥 Commits

Reviewing files that changed from the base of the PR and between f4900e2 and 455e754.

📒 Files selected for processing (3)
  • src/hooks/useLnurlSession.ts
  • src/lib/lnurl.ts
  • src/screens/Wallet/Receive/QrCode.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/screens/Wallet/Receive/QrCode.tsx

Comment thread src/hooks/useLnurlSession.ts Outdated
Comment thread src/lib/lnurl.ts Outdated
LnurlProvider now self-manages the session lifecycle — it starts when
the wallet identity and Boltz swaps are ready, and stops when they
aren't. The receive screen just reads lnurl/active/error from context.

Invoice requests are handled entirely in the provider: create reverse
swap, return the invoice, claim in background, notify on completion.
The receive screen's existing VTXO_UPDATE listener handles navigation.

Removes useLnurlSession hook (no longer needed).
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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/providers/lnurl.tsx`:
- Around line 227-241: The effect can start a session after cleanup because the
in-flight deriveLnurlCredentials(identity) resolution only checks
abortRef.current (which stopSession sets to null), so replace that check with a
local cancelled flag captured by the effect: declare let cancelled = false at
start of the useEffect, set cancelled = true in the cleanup (which should also
call stopSession()), and after awaiting deriveLnurlCredentials(identity) check
if (cancelled) return before calling startSession(creds); also remove the
unreachable else-if branch that checks abortRef.current (or change its logic) so
stopSession() is invoked only from cleanup; key symbols: useEffect,
deriveLnurlCredentials, abortRef, startSession, stopSession,
svcWallet?.identity, ready.
- Around line 96-108: Change handleInvoiceRequest to accept an optional
AbortSignal parameter (e.g. handleInvoiceRequest(amountMsat: number, signal?:
AbortSignal)), pass that signal through where appropriate, and after awaiting
createReverseSwapRef.current(sats) immediately check signal?.aborted and bail
out (throw or return) before starting swapsRef.current.waitAndClaim or calling
notifyRef; also ensure any long-running calls (like waitAndClaim) are either
cancelled via the signal if they support it or not started when aborted so you
don't leave orphaned swaps. Reference: handleInvoiceRequest,
createReverseSwapRef.current, swapsRef.current.waitAndClaim, notifyRef.
🪄 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: 12ba5009-e065-4318-a070-6de238963a56

📥 Commits

Reviewing files that changed from the base of the PR and between 455e754 and 046d751.

📒 Files selected for processing (3)
  • src/hooks/useLnurlSession.ts
  • src/providers/lnurl.tsx
  • src/screens/Wallet/Receive/QrCode.tsx
💤 Files with no reviewable changes (1)
  • src/hooks/useLnurlSession.ts

Comment thread src/providers/lnurl.tsx
Comment on lines +96 to +108
const handleInvoiceRequest = useCallback(async (amountMsat: number) => {
const sats = Math.floor(amountMsat / 1000)
const pendingSwap = await createReverseSwapRef.current(sats)
if (!pendingSwap) throw new Error('Failed to create reverse swap')
const swaps = swapsRef.current
if (swaps) {
swaps
.waitAndClaim(pendingSwap)
.then(() => notifyRef.current(pendingSwap.response.onchainAmount ?? 0))
.catch((err) => consoleError(err, 'Error claiming LNURL reverse swap'))
}
return pendingSwap.response.invoice
}, [])
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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

TypeScript strict mode behavior for excess arguments to a function literal typed via parameter list inference (e.g., useCallback)

💡 Result:

In TypeScript strict mode, when a function literal (arrow function) is passed to a context where its type is inferred from the expected parameter list (e.g., as a callback to useCallback without explicit type annotation, or to a higher-order function), TypeScript infers the exact parameter types and arity from the context. Calling this inferred function with excess arguments (more than expected) will produce a type error like "Expected N arguments, but got N+1". This is standard function call checking under strictFunctionTypes, which is enabled in strict mode.[1][2][3][4] This behavior occurs because the function type is contravariant in parameters: a function expecting specific parameters cannot safely accept calls with incompatible arguments.[1][5] Functions with fewer parameters are assignable to those expecting more (extra args ignored at runtime), but not vice versa.[3][4][5] For useCallback specifically: const cb = useCallback( => {}, []); infers => void. Calling cb(arg) errors: "Expected 0 arguments, but got 1".[6][7][8] Excess property checks apply to object literals, not function arguments directly.[9][10] No special strict mode exception for function literals vs declarations; inference makes the type exact for calls.[11] Example: const callMe: (x: string) => void = (() => console.log('no arg'))(); // Error: fewer params OK for assign, but if reverse... // But inferred: const inferred = (x: string) => console.log(x); inferred('a', 1); // Error: excess arg No changes noted up to TS 7.0 beta.[12]

Citations:


🏁 Script executed:

# First, let's check if the file exists and get its size
wc -l src/providers/lnurl.tsx

Repository: arkade-os/wallet

Length of output: 88


🏁 Script executed:

# Read the relevant sections of the file
echo "=== Lines 96-108 ==="
sed -n '96,108p' src/providers/lnurl.tsx

echo ""
echo "=== Lines 190-198 (around line 194) ==="
sed -n '190,198p' src/providers/lnurl.tsx

Repository: arkade-os/wallet

Length of output: 1160


🏁 Script executed:

# Search for all invocations of handleInvoiceRequest
rg "handleInvoiceRequest" src/providers/lnurl.tsx -n -A 2 -B 2

Repository: arkade-os/wallet

Length of output: 635


🏁 Script executed:

# Check for createReverseSwapRef definition and usage
rg "createReverseSwapRef" src/providers/lnurl.tsx -n -B 3 -A 3

Repository: arkade-os/wallet

Length of output: 879


🏁 Script executed:

# Search for other invocations of handleInvoiceRequest outside line 194
rg "handleInvoiceRequest\(" src/providers/lnurl.tsx -n | grep -v "const handleInvoiceRequest"

Repository: arkade-os/wallet

Length of output: 148


🏁 Script executed:

# Check TypeScript configuration for strict mode
cat tsconfig.json 2>/dev/null || cat tsconfig.*.json 2>/dev/null || echo "No tsconfig found"

Repository: arkade-os/wallet

Length of output: 598


🏁 Script executed:

# Find where createReverseSwap is imported or defined
rg "createReverseSwap" src/providers/lnurl.tsx -n -B 2 -A 2 | head -30

Repository: arkade-os/wallet

Length of output: 1069


🏁 Script executed:

# Search across the entire codebase for createReverseSwap definition
rg "function createReverseSwap|const createReverseSwap|export.*createReverseSwap" --type ts --type tsx -A 5

Repository: arkade-os/wallet

Length of output: 88


🏁 Script executed:

# Fix the rg command - use correct type flags
rg "function createReverseSwap|const createReverseSwap|export.*createReverseSwap" -t ts -t typescript -A 5

Repository: arkade-os/wallet

Length of output: 95


🏁 Script executed:

# Search for SwapsContext definition
rg "SwapsContext" src/ -n -B 2 -A 10 | head -50

Repository: arkade-os/wallet

Length of output: 4332


🏁 Script executed:

# Look for createReverseSwap in all files
fd . src/ -type f | xargs grep -l "createReverseSwap" | head -5

Repository: arkade-os/wallet

Length of output: 231


🏁 Script executed:

# Find the SwapsContext provider file
find src -name "*swaps*" -type f

Repository: arkade-os/wallet

Length of output: 84


🏁 Script executed:

# Search for SwapsContext provider definition
rg "SwapsContext.*createContext|export.*SwapsContext" -n -A 15

Repository: arkade-os/wallet

Length of output: 2001


🏁 Script executed:

# Look at the swaps provider file
head -100 src/providers/swaps.tsx 2>/dev/null || head -100 src/providers/swaps.ts 2>/dev/null

Repository: arkade-os/wallet

Length of output: 3785


handleInvoiceRequest signature mismatch — abort.signal is silently dropped.

handleInvoiceRequest is declared as (amountMsat: number) => Promise<string> at line 96, but it is invoked with two arguments at line 194: await handleInvoiceRequest(amountMsat, abort.signal). This is an excess-argument error under TypeScript strict mode, and at runtime the signal is ignored entirely — neither the background waitAndClaim nor createReverseSwap observe abort. If the session is torn down mid-request, the reverse swap is created and then postInvoice aborts, leaving an orphaned swap on the server with no invoice delivered to the LNURL caller.

Accept the signal in the callback and bail out before doing additional work after createReverseSwap resolves (before kicking off waitAndClaim).

🛠️ Proposed fix
-  const handleInvoiceRequest = useCallback(async (amountMsat: number) => {
+  const handleInvoiceRequest = useCallback(async (amountMsat: number, signal?: AbortSignal) => {
     const sats = Math.floor(amountMsat / 1000)
     const pendingSwap = await createReverseSwapRef.current(sats)
     if (!pendingSwap) throw new Error('Failed to create reverse swap')
+    if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
     const swaps = swapsRef.current
     if (swaps) {
       swaps
         .waitAndClaim(pendingSwap)
         .then(() => notifyRef.current(pendingSwap.response.onchainAmount ?? 0))
         .catch((err) => consoleError(err, 'Error claiming LNURL reverse swap'))
     }
     return pendingSwap.response.invoice
   }, [])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleInvoiceRequest = useCallback(async (amountMsat: number) => {
const sats = Math.floor(amountMsat / 1000)
const pendingSwap = await createReverseSwapRef.current(sats)
if (!pendingSwap) throw new Error('Failed to create reverse swap')
const swaps = swapsRef.current
if (swaps) {
swaps
.waitAndClaim(pendingSwap)
.then(() => notifyRef.current(pendingSwap.response.onchainAmount ?? 0))
.catch((err) => consoleError(err, 'Error claiming LNURL reverse swap'))
}
return pendingSwap.response.invoice
}, [])
const handleInvoiceRequest = useCallback(async (amountMsat: number, signal?: AbortSignal) => {
const sats = Math.floor(amountMsat / 1000)
const pendingSwap = await createReverseSwapRef.current(sats)
if (!pendingSwap) throw new Error('Failed to create reverse swap')
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
const swaps = swapsRef.current
if (swaps) {
swaps
.waitAndClaim(pendingSwap)
.then(() => notifyRef.current(pendingSwap.response.onchainAmount ?? 0))
.catch((err) => consoleError(err, 'Error claiming LNURL reverse swap'))
}
return pendingSwap.response.invoice
}, [])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/providers/lnurl.tsx` around lines 96 - 108, Change handleInvoiceRequest
to accept an optional AbortSignal parameter (e.g.
handleInvoiceRequest(amountMsat: number, signal?: AbortSignal)), pass that
signal through where appropriate, and after awaiting
createReverseSwapRef.current(sats) immediately check signal?.aborted and bail
out (throw or return) before starting swapsRef.current.waitAndClaim or calling
notifyRef; also ensure any long-running calls (like waitAndClaim) are either
cancelled via the signal if they support it or not started when aborted so you
don't leave orphaned swaps. Reference: handleInvoiceRequest,
createReverseSwapRef.current, swapsRef.current.waitAndClaim, notifyRef.

Comment thread src/providers/lnurl.tsx Outdated
Kukks added 2 commits May 5, 2026 21:50
Schnorr signatures (BIP340) use random auxRand by default, making
derived credentials non-deterministic across sessions. Switch to
HMAC-SHA256(privateKey, "lnurl-session") which is deterministic by
construction. Also fixes lint:fix script quoting for Windows.
Server now computes sessionId = SHA-256(token).slice(0, 32), so the
wallet only needs to send the token. Simplifies LnurlSessionCredentials
to { token } and removes redundant sessionId from derivation.
Copy link
Copy Markdown

@arkanaai arkanaai Bot left a comment

Choose a reason for hiding this comment

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

✅ Arkana Code Review — wallet#559 (3rd pass, new commits only)

Reviewing commits since my last review (455e7549):

  • 046d7513 — refactor: start LNURL session at wallet level, not receive screen
  • 121fbbcf — fix: use deterministic HMAC-SHA256 for LNURL credential derivation
  • 7739d521 — refactor: send only token to lnurl-server, let server derive sessionId

Previously flagged issues — resolved ✅

  1. CRITICAL: Credentials derived from public data → Fixed. Now uses HMAC-SHA256(privateKey, "lnurl-session"). Requires private key, deterministic, one-way. Cryptographically sound.
  2. HIGH: Non-deterministic Schnorr signatures → Fixed. HMAC is deterministic by construction. Same wallet → same token → same LNURL across sessions.
  3. Simplified credential model — server now derives sessionId = SHA-256(token)[0:32], wallet only sends token. Clean separation.

New findings

MEDIUM: No SSE reconnection logic — src/providers/lnurl.tsx:137-195

If the SSE stream to lnurl-server drops (server restart, network blip during mobile sleep/wake), the finally block (line ~205) clears state and sets active=false, but nothing triggers a reconnect. The useEffect (line ~228) depends on [credentials, connected, !!arkadeSwaps, swapsInitError] — none of these change when only the LNURL SSE drops.

Partial mitigation: a network outage that kills the SSE stream will likely also disconnect the Boltz WebSocket, toggling connected false→true and re-triggering the effect. But if only the lnurl-server goes down while Boltz stays up, the session dies silently.

Suggestion: add a reconnect-with-backoff in the finally block (only if abort.signal wasn't explicitly aborted), or set a disconnected state that the effect can react to. Not a blocker for merge — the deterministic credentials mean a reconnect will recover the same LNURL — but should be a fast follow-up.

LOW: Duplicate browser notifications — src/providers/lnurl.tsx:103 + src/screens/Wallet/Receive/QrCode.tsx:307

When the user IS on the receive screen and an LNURL payment completes:

  1. Provider's waitAndClaim callback calls notifyRef.current(amount) → browser notification
  2. QrCode.tsx's VTXO_UPDATE service worker listener calls notifyPaymentReceived(sats) → second browser notification

Result: two native notifications for one payment. Fix: either skip the notification in the provider (let the component handle it) or add a dedup guard. Low priority — cosmetic only.

NITPICK: Missing deps in useEffect — src/providers/lnurl.tsx:228-240

startSession and stopSession are not in the dependency array. Both are useCallback with stable deps so this is functionally safe, but it will trigger the react-hooks/exhaustive-deps ESLint rule. Consider adding them, or adding an // eslint-disable comment with a note.

Security summary

  • ✅ Private key used only as HMAC key — HMAC is a PRF, doesn't leak key material
  • ✅ Token (64 hex chars) is the HMAC output — not invertible to private key
  • ✅ SessionId derived by server via SHA-256(token) — public sessionId in QR doesn't reveal token
  • ✅ Bearer auth header uses token — only wallet owner can authenticate to their session
  • ✅ No protocol-critical code paths changed (VTXO handling, signing, forfeit, exit all untouched)

Verdict

Approving. The critical security issue from my first review is properly resolved. The HMAC-SHA256 credential scheme is clean and correct. The architectural move to a provider-level session is sound. The reconnection gap is worth a follow-up PR but not a blocker.

🤖 Reviewed by Arkana

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 (1)
src/providers/lnurl.tsx (1)

95-107: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Abort race still allows side effects after session teardown.

At Line 95, handleInvoiceRequest still has no abort gating, so a teardown can occur after reverse-swap creation but before invoice post, while waitAndClaim still starts. This can leave in-flight/orphaned work during session shutdown.

Suggested minimal fix
-  const handleInvoiceRequest = useCallback(async (amountMsat: number) => {
+  const handleInvoiceRequest = useCallback(async (amountMsat: number, signal?: AbortSignal) => {
     const sats = Math.floor(amountMsat / 1000)
     const pendingSwap = await createReverseSwapRef.current(sats)
     if (!pendingSwap) throw new Error('Failed to create reverse swap')
+    if (signal?.aborted) throw new DOMException('Aborted', 'AbortError')
     const swaps = swapsRef.current
     if (swaps) {
       swaps
         .waitAndClaim(pendingSwap)
         .then(() => notifyRef.current(pendingSwap.response.onchainAmount ?? 0))
         .catch((err) => consoleError(err, 'Error claiming LNURL reverse swap'))
     }
     return pendingSwap.response.invoice
   }, [])
-                    const pr = await handleInvoiceRequest(amountMsat)
+                    const pr = await handleInvoiceRequest(amountMsat, abort.signal)

Also applies to: 190-191

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/providers/lnurl.tsx` around lines 95 - 107, handleInvoiceRequest can
start side-effects after session teardown; after creating pendingSwap, re-check
an "active session" guard (e.g., a boolean like sessionActiveRef.current or an
AbortSignal) before starting swapsRef.current.waitAndClaim and before calling
notifyRef.current; if the session is torn down, skip starting waitAndClaim and
return the invoice without launching background work. Apply the same pattern for
the other handler referenced (around lines 190-191), and if waitAndClaim accepts
a cancellation token/signal prefer passing that so the operation can be aborted
instead of left running; also ensure errors still use consoleError.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@src/providers/lnurl.tsx`:
- Around line 95-107: handleInvoiceRequest can start side-effects after session
teardown; after creating pendingSwap, re-check an "active session" guard (e.g.,
a boolean like sessionActiveRef.current or an AbortSignal) before starting
swapsRef.current.waitAndClaim and before calling notifyRef.current; if the
session is torn down, skip starting waitAndClaim and return the invoice without
launching background work. Apply the same pattern for the other handler
referenced (around lines 190-191), and if waitAndClaim accepts a cancellation
token/signal prefer passing that so the operation can be aborted instead of left
running; also ensure errors still use consoleError.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8d0f3d6e-b1dd-4376-b4fb-2f7820f778e4

📥 Commits

Reviewing files that changed from the base of the PR and between 046d751 and 7739d52.

📒 Files selected for processing (3)
  • package.json
  • src/lib/lnurl.ts
  • src/providers/lnurl.tsx

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