From ae06a2f2e23302f4cdb3b4b8479ec1a88ca26247 Mon Sep 17 00:00:00 2001 From: louisinger Date: Sat, 15 Nov 2025 10:40:21 +0100 Subject: [PATCH 1/2] add lnurl auth --- src/lib/lnurl.ts | 49 +++++++++- src/providers/navigation.tsx | 5 + src/screens/Wallet/Send/Form.tsx | 9 +- src/screens/Wallet/Send/LnUrlLogin.tsx | 130 +++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 src/screens/Wallet/Send/LnUrlLogin.tsx diff --git a/src/lib/lnurl.ts b/src/lib/lnurl.ts index f4f19c445..2e3cc980e 100644 --- a/src/lib/lnurl.ts +++ b/src/lib/lnurl.ts @@ -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' 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,}))$/ @@ -27,6 +29,7 @@ type LnUrlCallbackResponse = { } const checkResponse = (response: Response): Promise => { + console.log('response', response) if (!response.ok) return Promise.reject(response) return response.json() } @@ -75,6 +78,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 => { + 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 => { return new Promise((resolve, reject) => { const url = getCallbackUrl(lnurl) diff --git a/src/providers/navigation.tsx b/src/providers/navigation.tsx index df3fba2b3..9412d21d6 100644 --- a/src/providers/navigation.tsx +++ b/src/providers/navigation.tsx @@ -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' @@ -50,6 +51,7 @@ export enum Pages { SendForm, SendDetails, SendSuccess, + LnUrlLogin, Settings, Transaction, Unlock, @@ -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, @@ -140,6 +143,8 @@ export const pageComponent = (page: Pages): JSX.Element => { return case Pages.SendSuccess: return + case Pages.LnUrlLogin: + return case Pages.Settings: return case Pages.Transaction: diff --git a/src/screens/Wallet/Send/Form.tsx b/src/screens/Wallet/Send/Form.tsx index 05cdccdcd..06397ce07 100644 --- a/src/screens/Wallet/Send/Form.tsx +++ b/src/screens/Wallet/Send/Form.tsx @@ -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' import { extractError } from '../../../lib/error' import { getInvoiceSatoshis } from '@arkade-os/boltz-swap' import { LightningContext } from '../../../providers/lightning' @@ -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') diff --git a/src/screens/Wallet/Send/LnUrlLogin.tsx b/src/screens/Wallet/Send/LnUrlLogin.tsx new file mode 100644 index 000000000..e090ba7e4 --- /dev/null +++ b/src/screens/Wallet/Send/LnUrlLogin.tsx @@ -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 + } + + const tableData = [ + ['Service', domain, ], + ['Challenge', k1, ], + ] + + return ( + <> +
+ + {loading ? ( + + ) : success ? ( + + ) : ( + + + + + + + + )} + + + {loading ? null : success ? ( +