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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion src/lib/lnurl.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { bech32, utf8 } from '@scure/base'
import { Identity } from '@arkade-os/sdk'
import { secp256k1 } from '@noble/curves/secp256k1.js'
import { bech32, hex, utf8 } from '@scure/base'
Comment on lines +1 to +3
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 | 🔴 Critical

🧩 Analysis chain

parseLoginLnUrl should tolerate non-bech32 lnurls to avoid crashes; loginLnUrl shape looks OK

parseLoginLnUrl currently assumes that every lnurl argument is a bech32-encoded LNURL and immediately does bech32.decodeToBytes(lnurl). Callers like SendForm can pass lightning addresses (e.g. user@domain.com) or, in the future, raw URLs (LUD-17 style). In those cases bech32.decodeToBytes will throw, and because callers don’t wrap parseLoginLnUrl in a try/catch, the whole React effect will crash.

You can fix this centrally (and make the caller code simpler) by:

  • Treating bech32 decoding as best-effort.
  • Falling back to the original lnurl string if decoding fails.
  • Safely handling URLs without a ? when building URLSearchParams.
  • Only returning k1 when tag=login and k1 is present.

For example:

-export const parseLoginLnUrl = (lnurl: string): { k1?: string; url: string } => {
-  const { bytes } = bech32.decodeToBytes(lnurl)
-  const url = utf8.encode(bytes)
-  const queryParams = new URLSearchParams(url.split('?')[1])
-  const tag = queryParams.get('tag')
-  if (tag === 'login') {
-    return { k1: queryParams.get('k1')!, url }
-  }
-
-  return { url }
-}
+export const parseLoginLnUrl = (lnurl: string): { k1?: string; url: string } => {
+  // Try to decode classic bech32-encoded LNURLs; fall back to raw value on failure
+  let url = lnurl
+  try {
+    const { bytes } = bech32.decodeToBytes(lnurl)
+    url = utf8.encode(bytes)
+  } catch {
+    // Non-bech32 input (e.g. lightning address or raw URL) – ignore and use as-is
+  }
+
+  const queryIndex = url.indexOf('?')
+  if (queryIndex === -1) return { url }
+
+  const queryParams = new URLSearchParams(url.slice(queryIndex + 1))
+  const tag = queryParams.get('tag')
+  const k1 = queryParams.get('k1')
+  if (tag === 'login' && k1) {
+    return { k1, url }
+  }
+
+  return { url }
+}

With this change:

  • Callers in SendForm and LnUrlLogin can safely call parseLoginLnUrl for any lnurl (bech32, lightning address, or raw URL) without worrying about exceptions.
  • Only true login LNURLs with a k1 will trigger the auth flow; lightning addresses and pay/withdraw LNURLs will fall through to the existing payment logic.

The loginLnUrl helper that signs k1, injects sig and key into the query string, and uses a CORS proxy on localhost looks structurally fine; once the above parser change is in place, I’d just recommend exercising it against a couple of real LNURL-auth services to confirm the signature encoding and query format match what they expect.

Also applies to: 80-122


🏁 Script executed:

#!/bin/bash
# Quick grep to find all call sites of parseLoginLnUrl and verify they no longer need local try/catch.
rg -n "parseLoginLnUrl" -C3

Length of output: 3215


parseLoginLnUrl must tolerate non-bech32 lnurls to prevent crashes; fix confirmed needed at Form.tsx:162 and LnUrlLogin.tsx:39

Verification confirms the critical issue: parseLoginLnUrl assumes bech32 encoding and calls bech32.decodeToBytes(lnurl) without fallback. Call sites at src/screens/Wallet/Send/Form.tsx:162 and src/screens/Wallet/Send/LnUrlLogin.tsx:39 are unprotected by try/catch. When callers (SendForm, LnUrlLogin effects) pass lightning addresses or raw URLs, decodeToBytes throws and crashes the React effect.

Implement the proposed fix to make parseLoginLnUrl tolerant:

-export const parseLoginLnUrl = (lnurl: string): { k1?: string; url: string } => {
-  const { bytes } = bech32.decodeToBytes(lnurl)
-  const url = utf8.encode(bytes)
-  const queryParams = new URLSearchParams(url.split('?')[1])
-  const tag = queryParams.get('tag')
-  if (tag === 'login') {
-    return { k1: queryParams.get('k1')!, url }
-  }
-
-  return { url }
-}
+export const parseLoginLnUrl = (lnurl: string): { k1?: string; url: string } => {
+  // Try to decode classic bech32-encoded LNURLs; fall back to raw value on failure
+  let url = lnurl
+  try {
+    const { bytes } = bech32.decodeToBytes(lnurl)
+    url = utf8.encode(bytes)
+  } catch {
+    // Non-bech32 input (e.g. lightning address or raw URL) – ignore and use as-is
+  }
+
+  const queryIndex = url.indexOf('?')
+  if (queryIndex === -1) return { url }
+
+  const queryParams = new URLSearchParams(url.slice(queryIndex + 1))
+  const tag = queryParams.get('tag')
+  const k1 = queryParams.get('k1')
+  if (tag === 'login' && k1) {
+    return { k1, url }
+  }
+
+  return { url }
+}

This eliminates the crash risk and allows Form.tsx:162 and LnUrlLogin.tsx:39 to safely handle any LNURL format. Only true login LNURLs with k1 will trigger auth; others fall through to existing payment logic.

Committable suggestion skipped: line range outside the PR's diff.


const emailRegex =
/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
Expand Down Expand Up @@ -75,6 +77,50 @@ export const getCallbackUrl = (lnurl: string): string => {
return utf8.encode(bytes)
}

export const parseLoginLnUrl = (lnurl: string): { k1?: string; url: string } => {
const { bytes } = bech32.decodeToBytes(lnurl)
const url = utf8.encode(bytes)
const queryParams = new URLSearchParams(url.split('?')[1])
const tag = queryParams.get('tag')
if (tag === 'login') {
return { k1: queryParams.get('k1')!, url }
}

return { url }
}

export const loginLnUrl = async (lnurl: string, k1: string, identity: Identity): Promise<Response> => {
const k1bytes = hex.decode(k1)
const sig = await identity.signMessage(k1bytes, 'ecdsa')
const key = await identity.compressedPublicKey()
const url = new URL(lnurl)

const derSigEncoding = secp256k1.Signature.fromBytes(sig).toHex('der')
url.searchParams.delete('tag')
url.searchParams.set('sig', derSigEncoding)
url.searchParams.set('key', hex.encode(key))

const targetUrl = url.toString()

// if current page is localhost, use cors proxy
if (window.location.hostname === 'localhost') {
const corsProxyUrl = `https://api.allorigins.win/raw?url=${encodeURIComponent(targetUrl)}`
return fetch(corsProxyUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
},
})
}

return fetch(targetUrl, {
method: 'GET',
headers: {
Accept: 'application/json',
},
})
}

export const checkLnUrlConditions = (lnurl: string): Promise<LnUrlResponse> => {
return new Promise<LnUrlResponse>((resolve, reject) => {
const url = getCallbackUrl(lnurl)
Expand Down
5 changes: 5 additions & 0 deletions src/providers/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import ReceiveSuccess from '../screens/Wallet/Receive/Success'
import SendForm from '../screens/Wallet/Send/Form'
import SendDetails from '../screens/Wallet/Send/Details'
import SendSuccess from '../screens/Wallet/Send/Success'
import LnUrlLogin from '../screens/Wallet/Send/LnUrlLogin'
import Transaction from '../screens/Wallet/Transaction'
import Unlock from '../screens/Wallet/Unlock'
import Vtxos from '../screens/Settings/Vtxos'
Expand Down Expand Up @@ -50,6 +51,7 @@ export enum Pages {
SendForm,
SendDetails,
SendSuccess,
LnUrlLogin,
Settings,
Transaction,
Unlock,
Expand Down Expand Up @@ -87,6 +89,7 @@ const pageTab = {
[Pages.SendForm]: Tabs.Wallet,
[Pages.SendDetails]: Tabs.Wallet,
[Pages.SendSuccess]: Tabs.Wallet,
[Pages.LnUrlLogin]: Tabs.Wallet,
[Pages.Settings]: Tabs.Settings,
[Pages.Transaction]: Tabs.Wallet,
[Pages.Unlock]: Tabs.None,
Expand Down Expand Up @@ -140,6 +143,8 @@ export const pageComponent = (page: Pages): JSX.Element => {
return <SendDetails />
case Pages.SendSuccess:
return <SendSuccess />
case Pages.LnUrlLogin:
return <LnUrlLogin />
case Pages.Settings:
return <Settings />
case Pages.Transaction:
Expand Down
9 changes: 8 additions & 1 deletion src/screens/Wallet/Send/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { ConfigContext } from '../../../providers/config'
import { FiatContext } from '../../../providers/fiat'
import { ArkNote } from '@arkade-os/sdk'
import { LimitsContext } from '../../../providers/limits'
import { checkLnUrlConditions, fetchInvoice, fetchArkAddress, isValidLnUrl } from '../../../lib/lnurl'
import { checkLnUrlConditions, fetchInvoice, fetchArkAddress, isValidLnUrl, parseLoginLnUrl } from '../../../lib/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 | 🔴 Critical

LNURL login detection will currently crash for lightning addresses

sendInfo.lnUrl is populated via isValidLnUrl, which accepts both bech32 LNURLs and lightning addresses. This effect unconditionally calls parseLoginLnUrl(sendInfo.lnUrl), but parseLoginLnUrl assumes a bech32-encoded LNURL and will throw when given a lightning address, causing this effect (and thus the Send form) to crash.

I’d handle this at the parser level (see src/lib/lnurl.ts comment) by making parseLoginLnUrl tolerant of non-bech32 inputs so this effect can safely call it for any sendInfo.lnUrl value and only navigate to Pages.LnUrlLogin when a real login LNURL with k1 is detected.

Also applies to: 158-167

🤖 Prompt for AI Agents
In src/screens/Wallet/Send/Form.tsx around line 37 (and similarly lines
158-167), the effect unconditionally calls parseLoginLnUrl(sendInfo.lnUrl) but
sendInfo.lnUrl can be a lightning address (not bech32) which causes
parseLoginLnUrl to throw; update parseLoginLnUrl in src/lib/lnurl.ts to be
tolerant of non-bech32 inputs (return null or a safe result when input is not a
bech32 login LNURL) and then change the effect(s) to call the safe parser and
only navigate to Pages.LnUrlLogin when the parsed result exists and includes the
expected login type with a k1 parameter; ensure no unguarded parsing occurs and
handle null/invalid returns without throwing.

import { extractError } from '../../../lib/error'
import { getInvoiceSatoshis } from '@arkade-os/boltz-swap'
import { LightningContext } from '../../../providers/lightning'
Expand Down Expand Up @@ -159,6 +159,13 @@ export default function SendForm() {
useEffect(() => {
if (!sendInfo.lnUrl) return
if (sendInfo.lnUrl && sendInfo.invoice) return
const { k1 } = parseLoginLnUrl(sendInfo.lnUrl)
if (k1) {
// Navigate to login confirmation screen
navigate(Pages.LnUrlLogin)
return
}

checkLnUrlConditions(sendInfo.lnUrl)
.then((conditions) => {
if (!conditions) return setError('Unable to fetch LNURL conditions')
Expand Down
130 changes: 130 additions & 0 deletions src/screens/Wallet/Send/LnUrlLogin.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { useContext, useEffect, useState } from 'react'
import Button from '../../../components/Button'
import ErrorMessage from '../../../components/Error'
import ButtonsOnBottom from '../../../components/ButtonsOnBottom'
import { NavigationContext, Pages } from '../../../providers/navigation'
import { FlowContext } from '../../../providers/flow'
import Padded from '../../../components/Padded'
import Header from '../../../components/Header'
import Content from '../../../components/Content'
import FlexCol from '../../../components/FlexCol'
import Loading from '../../../components/Loading'
import { WalletContext } from '../../../providers/wallet'
import { loginLnUrl, parseLoginLnUrl } from '../../../lib/lnurl'
import { extractError } from '../../../lib/error'
import { consoleError } from '../../../lib/logs'
import WarningBox from '../../../components/Warning'
import Table from '../../../components/Table'
import TypeIcon from '../../../icons/Type'
import InfoIcon from '../../../icons/Info'
import Success from '../../../components/Success'

export default function LnUrlLogin() {
const { sendInfo, setSendInfo } = useContext(FlowContext)
const { navigate } = useContext(NavigationContext)
const { svcWallet } = useContext(WalletContext)

const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false)
const [domain, setDomain] = useState('')
const [k1, setK1] = useState('')

useEffect(() => {
if (!sendInfo.lnUrl) {
navigate(Pages.SendForm)
return
}

const { k1: parsedK1, url } = parseLoginLnUrl(sendInfo.lnUrl)
if (!parsedK1) {
navigate(Pages.SendForm)
return
}

setK1(parsedK1)

// Extract domain from URL
try {
const urlObj = new URL(url)
setDomain(urlObj.hostname)
} catch {
setDomain('Unknown service')
}
}, [sendInfo.lnUrl, navigate])

const handleConfirm = async () => {
if (!sendInfo.lnUrl || !svcWallet || !k1) return

setLoading(true)
setError('')

try {
const { url } = parseLoginLnUrl(sendInfo.lnUrl)
const response = await loginLnUrl(url, k1, svcWallet.identity)
const data = await response.json()

if (data.status === 'OK') {
setSuccess(true)
setLoading(false)
} else {
setError(data.reason || 'Login failed')
setLoading(false)
}
} catch (err) {
consoleError(err, 'error logging in with LNURL')
setError(extractError(err) || 'Failed to login')
setLoading(false)
}
}

const handleCancel = () => {
setSendInfo({ ...sendInfo, lnUrl: undefined })
navigate(Pages.SendForm)
}

const handleBack = () => {
setSendInfo({ ...sendInfo, lnUrl: undefined })
navigate(Pages.Wallet)
}

if (!sendInfo.lnUrl || !k1) {
return <Loading text='Loading...' />
}

const tableData = [
['Service', domain, <TypeIcon key='service-icon' />],
['Challenge', k1, <InfoIcon key='challenge-icon' />],
]

return (
<>
<Header text='LNURL Login' back={success ? handleBack : handleCancel} />
<Content>
{loading ? (
<Loading text='Signing...' />
) : success ? (
<Success headline='Login successful!' text={`You have successfully signed in to ${domain}`} />
) : (
<Padded>
<FlexCol>
<ErrorMessage error={Boolean(error)} text={error} />
<WarningBox text='By signing this message, you are authenticating yourself to this service. Make sure you trust this website before proceeding.' />
<Table data={tableData} />
</FlexCol>
</Padded>
)}
</Content>
<ButtonsOnBottom>
{loading ? null : success ? (
<Button onClick={handleBack} label='Back to Wallet' />
) : (
<>
<Button onClick={handleCancel} label='Cancel' secondary />
<Button onClick={handleConfirm} label='Sign' disabled={Boolean(error)} />
</>
)}
</ButtonsOnBottom>
</>
)
}