From 1c8e74b0db93443ea609fe72afb469cd84e50723 Mon Sep 17 00:00:00 2001 From: First-Terraner <40151034+KKA11010@users.noreply.github.com> Date: Wed, 27 Mar 2024 22:39:46 +0100 Subject: [PATCH] Update UI to handle encoded LNURL (#324) * add lnurl.ts & lint * show lnurl address in amount selection screen * update UI & rename functions * fix require cycle. replace onion regex with string.endsWith() * fix ci * remove node 16.x from CI workflow * Add support for Node.js 21.x * fix going back from amount selection screen * update translations --- .github/workflows/node.js.yml | 2 +- assets/translations/de.json | 2 +- assets/translations/en.json | 2 +- assets/translations/es.json | 2 +- assets/translations/fr.json | 2 +- assets/translations/hu.json | 2 +- assets/translations/sw.json | 2 +- package-lock.json | 2 +- src/model/index.ts | 9 ++ src/model/nav.ts | 19 ++- src/nostr/consts.ts | 4 +- src/screens/Addressbook/index.tsx | 3 +- src/screens/Payment/Processing.tsx | 5 +- src/screens/Payment/SelectAmount.tsx | 39 ++++-- src/screens/Payment/Send/CoinSelection.tsx | 5 +- src/screens/Payment/Send/Inputfield.tsx | 17 ++- src/screens/Payment/Send/SelectTarget.tsx | 2 +- src/screens/QRScan/QRProcessing.tsx | 140 ++++++++++++++++++--- src/screens/QRScan/index.tsx | 35 ++---- src/util/index.ts | 132 +------------------ src/util/lnurl.ts | 136 ++++++++++++++++++++ 21 files changed, 358 insertions(+), 204 deletions(-) create mode 100644 src/util/lnurl.ts diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index d93ec997..9c8a4e64 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -19,7 +19,7 @@ jobs: strategy: matrix: - node-version: [16.x, 18.x, 20.x] + node-version: [18.x, 20.x, 21.x] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: diff --git a/assets/translations/de.json b/assets/translations/de.json index c9bbec5b..5c1b66f8 100644 --- a/assets/translations/de.json +++ b/assets/translations/de.json @@ -318,7 +318,7 @@ "invoiceHint": "Das kann einige Sekunden dauern...", "lowBal": "Kein Guthaben verfügbar", "meltAddressbookHint": "Wählen Sie Ihre eigene LNURL oder einen anderen Kontakt als Zahlungsempfänger aus.", - "meltInputHint": "Erstellen Sie eine Lightning-Rechnung oder geben Sie eine LNURL ein.", + "meltInputHint": "Fügen Sie eine Lightning-Rechnung, LNURL oder Lightning Adresse ein.", "meltScanQRHint": "Erstellen Sie eine Lightning-Rechnung mit einem anderen Gerät und scannen Sie sie einfach.", "meltSwapHint": "Wählen Sie eine andere Mint aus Ihrer vertrauenswürdigen Liste als Zahlungsempfänger aus.", "copyShareToken": "Kopieren & teilen", diff --git a/assets/translations/en.json b/assets/translations/en.json index f9ca3264..44dec848 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -318,7 +318,7 @@ "invoiceHint": "This can take a few seconds...", "lowBal": "Mint balance too low!", "meltAddressbookHint": "Choose your own LNURL or any other contact as a payment receiver.", - "meltInputHint": "Create a Lightning invoice or paste a LNURL into an input field.", + "meltInputHint": "Paste Lightning invoice, LN-URL or Lightning address.", "meltScanQRHint": "Create a Lightning invoice with another device and simply scan it.", "meltSwapHint": "Pick another mint from your trusted list as the payment receiver.", "copyShareToken": "Copy & quickshare", diff --git a/assets/translations/es.json b/assets/translations/es.json index 199ae4cb..71b89a13 100644 --- a/assets/translations/es.json +++ b/assets/translations/es.json @@ -318,7 +318,7 @@ "invoiceHint": "Esto puede llevar unos segundos...", "lowBal": "¡Saldo de la ceca muy bajo!", "meltAddressbookHint": "Elige tu propia LNURL o cualquier otro contacto como receptor del pago.", - "meltInputHint": "Crea un recibo Lightning o pega una LNURL en el campo de entrada.", + "meltInputHint": "Pega la factura de Lightning, la LN-URL o la dirección de Lightning.", "meltScanQRHint": "Crea un recibo Lightning con otro dispositivo y simplemente escanéalo.", "meltSwapHint": "Elige otra ceca de tu lista de confianza como receptora del pago.", "copyShareToken": "Copiar y compartir rápido", diff --git a/assets/translations/fr.json b/assets/translations/fr.json index 09339ccb..526de411 100644 --- a/assets/translations/fr.json +++ b/assets/translations/fr.json @@ -318,7 +318,7 @@ "invoiceHint": "Cela peut prendre quelques secondes...", "lowBal": "Solde trop bas!", "meltAddressbookHint": "Sélectionnez votre propre LNURL ou tout autre contact comme destinataire du paiement.", - "meltInputHint": "Créez une facture Lightning ou insérez une LNURL valide.", + "meltInputHint": "Collez une facture Lightning, LN-URL ou l'adresse Lightning.", "meltScanQRHint": "Créez une facture Lightning avec un autre appareil et scannez-la simplement.", "meltSwapHint": "Sélectionnez une autre mint de votre liste de confiance comme destinataire du paiement.", "copyShareToken": "Copier et partager", diff --git a/assets/translations/hu.json b/assets/translations/hu.json index 898ee643..56461943 100644 --- a/assets/translations/hu.json +++ b/assets/translations/hu.json @@ -318,7 +318,7 @@ "invoiceHint": "Ez eltarthat pár másodpercig...", "lowBal": "Verde egyenlege túl alacsony!", "meltAddressbookHint": "Válaszd ki a saját LNURL-ed vagy bármelyik névjegyed a fizetés fogadójának.", - "meltInputHint": "Hozz létre egy Lightning számlát vagy illessz be egy LNURL-t a bemeneti mezőbe.", + "meltInputHint": "Illessze be a Lightning számlát, az LN-URL vagy a Lightning címet.", "meltScanQRHint": "Hozz létre egy Lightning számlát egy másik eszközön és csak olvasd be.", "meltSwapHint": "Válassz egy másik verdét a listádról a fizetés fogadójának.", "copyShareToken": "Másol & megosztás", diff --git a/assets/translations/sw.json b/assets/translations/sw.json index 9841424a..2e84a8db 100644 --- a/assets/translations/sw.json +++ b/assets/translations/sw.json @@ -318,7 +318,7 @@ "invoiceHint": "Hii inaweza kuchukua sekunde kadhaa...", "lowBal": "Salio la sarafu limepungua sana!", "meltAddressbookHint": "Chagua LNURL yako mwenyewe au mawasiliano mengine yoyote kama mpokeaji wa malipo.", - "meltInputHint": "Unda ankara ya Lightning au weka LNURL kwenye uga wa kuingiza.", + "meltInputHint": "Weka ankara ya Lightning, LN-URL au anwani ya Lightning.", "meltScanQRHint": "Unda ankara ya Lightning kwa kutumia kifaa kingine na ui-scan tu.", "meltSwapHint": "Chagua sarafu nyingine kutoka kwa orodha yako ya sarafu za kuaminika kama mpokeaji wa malipo.", "copyShareToken": "Nakili & Shiriki kwa haraka", diff --git a/package-lock.json b/package-lock.json index 3cef8810..5fe14241 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@shopify/flash-list": "1.6.3", "bech32": "^2.0.0", "crypto-js": "4.2.0", - "expo": "^50.0.8", + "expo": "^50.0.14", "expo-application": "~5.8.3", "expo-barcode-scanner": "~12.9.3", "expo-camera": "~14.1.1", diff --git a/src/model/index.ts b/src/model/index.ts index 7ec31d97..aaf9c7c8 100644 --- a/src/model/index.ts +++ b/src/model/index.ts @@ -30,6 +30,15 @@ export interface ILnUrl { callback: string pr: string } + +// TODO This interface is missing some properties? +export interface ILnUrlPayRequest { + tag: string + cb: string + minSendable: number + maxSendable: number + metadata: string +} export interface IMint { id: string mintUrl: string diff --git a/src/model/nav.ts b/src/model/nav.ts index bc33edb1..a4d7c007 100644 --- a/src/model/nav.ts +++ b/src/model/nav.ts @@ -1,13 +1,19 @@ import type { EventArg } from '@react-navigation/core' import type { NativeStackScreenProps } from '@react-navigation/native-stack' -import type { IHistoryEntry, IMintUrl, IMintWithBalance, IProofSelection, ITokenInfo } from '.' +import type { IHistoryEntry, ILnUrlPayRequest, IMintUrl, IMintWithBalance, IProofSelection, ITokenInfo } from '.' import type { HexKey, IContact } from './nostr' export interface INostrSendData { senderName: string contact?: IContact } + +interface ILnurlNavData { + userInput: string + url?: string + data?: ILnUrlPayRequest +} /** * Stack Navigator */ @@ -48,6 +54,7 @@ export type RootStackParamList = { invoice?: string invoiceAmount?: number estFee?: number + lnurl?: ILnurlNavData scanned?: boolean }, selectTarget: { @@ -74,8 +81,9 @@ export type RootStackParamList = { nostr?: INostrSendData isSwap?: boolean balance: number - lnurl?: string + lnurl?: ILnurlNavData targetMint?: IMintUrl + scanned?: boolean } selectNostrAmount: { mint: IMintUrl @@ -118,12 +126,19 @@ export type RootStackParamList = { 'qr processing': { tokenInfo?: ITokenInfo token?: string + scanned?: boolean ln?: { invoice: string mint?: IMintUrl balance?: number amount: number } + lnurl?: { + mint?: IMintUrl + balance?: number + url: string + data: string + } } 'mint confirm': { mintUrl: string diff --git a/src/nostr/consts.ts b/src/nostr/consts.ts index 52498b47..aa478034 100644 --- a/src/nostr/consts.ts +++ b/src/nostr/consts.ts @@ -1,5 +1,5 @@ -import { Npub } from '@src/model/nostr' -import { isUrl } from '@src/util' +import type { Npub } from '@model/nostr' +import { isUrl } from '@util/lnurl' import { normalizeURL } from './util' diff --git a/src/screens/Addressbook/index.tsx b/src/screens/Addressbook/index.tsx index 8b21bb7f..e8740696 100644 --- a/src/screens/Addressbook/index.tsx +++ b/src/screens/Addressbook/index.tsx @@ -295,8 +295,7 @@ export default function AddressbookPage({ navigation, route }: TAddressBookPageP openPromptAutoClose({ msg: t('receiverNoLnurl', { ns: NS.addrBook }) }) return } - navigation.navigate('selectAmount', { isMelt, lnurl: contact.lud16, mint, balance }) - return + return navigation.navigate('selectAmount', { isMelt, lnurl: { userInput: contact.lud16 }, mint, balance }) } if (!nostrRef.current) { return } // mint has already been selected diff --git a/src/screens/Payment/Processing.tsx b/src/screens/Payment/Processing.tsx index 02305596..db365a72 100644 --- a/src/screens/Payment/Processing.tsx +++ b/src/screens/Payment/Processing.tsx @@ -10,11 +10,12 @@ import { useInitialURL } from '@src/context/Linking' import { useNostrContext } from '@src/context/Nostr' import { useThemeContext } from '@src/context/Theme' import { NS } from '@src/i18n' +import { isLnurlOrAddress } from '@src/util/lnurl' import { addLnPaymentToHistory } from '@store/HistoryStore' import { addToHistory, updateLatestHistory } from '@store/latestHistoryEntries' import { getDefaultMint } from '@store/mintStore' import { globals } from '@styles' -import { decodeLnInvoice, getInvoiceFromLnurl, isErr, isLnurlOrAddress, isNum, uniqByIContacts } from '@util' +import { decodeLnInvoice, getInvoiceFromLnurl, isErr, isNum, uniqByIContacts } from '@util' import { autoMintSwap, checkFees, fullAutoMintSwap, getHighestBalMint, payLnInvoice, requestMint, sendToken } from '@wallet' import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -98,7 +99,7 @@ export default function ProcessingScreen({ navigation, route }: TProcessingPageP const handleMelting = async () => { let invoice = '' - // recipient can be a LNURL (address) or a LN invoice + // recipient can be a LNURL or a LN invoice if (recipient?.length && isLnurlOrAddress(recipient)) { try { invoice = await getInvoiceFromLnurl(recipient, +amount) diff --git a/src/screens/Payment/SelectAmount.tsx b/src/screens/Payment/SelectAmount.tsx index fc2f3021..47132420 100644 --- a/src/screens/Payment/SelectAmount.tsx +++ b/src/screens/Payment/SelectAmount.tsx @@ -13,7 +13,8 @@ import { usePromptContext } from '@src/context/Prompt' import { useThemeContext } from '@src/context/Theme' import { NS } from '@src/i18n' import { globals, highlight as hi, mainColors } from '@styles' -import { cleanUpNumericStr, formatSatStr, getInvoiceFromLnurl, vib } from '@util' +import { cleanUpNumericStr, formatInt, formatSatStr, getInvoiceFromLnurl, vib } from '@util' +import { getLnurlIdentifierFromMetadata, isLightningAddress } from '@util/lnurl' import { checkFees, requestMint } from '@wallet' import { createRef, useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -21,7 +22,7 @@ import { Animated, KeyboardAvoidingView, TextInput, View } from 'react-native' import { s, ScaledSheet, vs } from 'react-native-size-matters' export default function SelectAmountScreen({ navigation, route }: TSelectAmountPageProps) { - const { mint, balance, lnurl, isMelt, isSendEcash, nostr, isSwap, targetMint } = route.params + const { mint, balance, lnurl, isMelt, isSendEcash, nostr, isSwap, targetMint, scanned } = route.params const { openPromptAutoClose } = usePromptContext() const { t } = useTranslation([NS.wallet]) const { color, highlight } = useThemeContext() @@ -53,14 +54,14 @@ export default function SelectAmountScreen({ navigation, route }: TSelectAmountP return 'createInvoice' } - const handleFeeEstimation = async (lnurl: string) => { + const handleFeeEstimation = async () => { setFee(prev => ({ ...prev, isCalculating: true })) try { // check fee for payment to lnurl - if (lnurl.length) { - const lnurlInvoice = await getInvoiceFromLnurl(lnurl, +amount) + if (lnurl) { + const lnurlInvoice = await getInvoiceFromLnurl(lnurl.userInput, +amount) if (!lnurlInvoice?.length) { - openPromptAutoClose({ msg: t('feeErr', { ns: NS.common, input: lnurl }) }) + openPromptAutoClose({ msg: t('feeErr', { ns: NS.common, input: lnurl.url }) }) return setFee(prev => ({ ...prev, isCalculating: false })) } const estFee = await checkFees(mint.mintUrl, lnurlInvoice) @@ -106,11 +107,12 @@ export default function SelectAmountScreen({ navigation, route }: TSelectAmountP return } // estimate melting/swap fee - if (!isSendEcash && shouldEstimate && (lnurl?.length || isSwap)) { - return handleFeeEstimation(lnurl || '') + if (!isSendEcash && shouldEstimate && (lnurl || isSwap)) { + return handleFeeEstimation() } // send ecash / melt / swap if (isSendingTX) { + const recipient = isLightningAddress(lnurl?.userInput || '') ? lnurl?.userInput : lnurl?.data ? getLnurlIdentifierFromMetadata(lnurl.data?.metadata) : undefined // Check if user melts/swaps his whole mint balance, so there is no need for coin selection and that can be skipped here if (!isSendEcash && isSendingWholeMintBal()) { return navigation.navigate('processing', { @@ -121,7 +123,7 @@ export default function SelectAmountScreen({ navigation, route }: TSelectAmountP isSendEcash, isSwap, targetMint, - recipient: lnurl + recipient }) } return navigation.navigate('coinSelection', { @@ -135,7 +137,7 @@ export default function SelectAmountScreen({ navigation, route }: TSelectAmountP isSendEcash, isSwap, targetMint, - recipient: lnurl + recipient }) } // request new token from mint @@ -169,7 +171,7 @@ export default function SelectAmountScreen({ navigation, route }: TSelectAmountP navigation.goBack()} + handlePress={() => scanned ? navigation.navigate('qr scan', {}) : navigation.goBack()} mintBalance={balance} disableMintBalance={isMelt || isSwap} handleMintBalancePress={() => setAmount(`${balance}`)} @@ -181,6 +183,21 @@ export default function SelectAmountScreen({ navigation, route }: TSelectAmountP /> } + {lnurl && (lnurl.data || lnurl.userInput) && + + } { if (recipient) { - return !isLnurlOrAddress(recipient) ? truncateStr(recipient, 16) : recipient + return !isLightningAddress(recipient) ? truncateStr(recipient, 16) : recipient } const npub = npubEncode(nostr?.contact?.hex ?? '') const receiverName = getNostrUsername(nostr?.contact) diff --git a/src/screens/Payment/Send/Inputfield.tsx b/src/screens/Payment/Send/Inputfield.tsx index d990aaac..298adb0b 100644 --- a/src/screens/Payment/Send/Inputfield.tsx +++ b/src/screens/Payment/Send/Inputfield.tsx @@ -10,7 +10,8 @@ import { usePromptContext } from '@src/context/Prompt' import { useThemeContext } from '@src/context/Theme' import { NS } from '@src/i18n' import { globals } from '@styles' -import { decodeLnInvoice, getStrFromClipboard, isErr, isLnurlOrAddress, openUrl } from '@util' +import { decodeLnInvoice, getStrFromClipboard, isErr, openUrl } from '@util' +import { decodeUrlOrAddress, getLnurlData, isLnurlOrAddress } from '@util/lnurl' import { checkFees } from '@wallet' import { createRef, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -65,16 +66,24 @@ export default function InputfieldScreen({ navigation, route }: TMeltInputfieldP } } - const handleBtnPress = () => { + const handleBtnPress = async () => { if (loading) { return } // open user LN wallet if (!input.length) { return openUrl('lightning://')?.catch(e => openPromptAutoClose({ msg: isErr(e) ? e.message : t('deepLinkErr') })) } - // user pasted a LNURL, we need to get the amount by the user + // user pasted an encoded LNURL, we need to get the amount by the user if (isLnurlOrAddress(input)) { - return navigation.navigate('selectAmount', { mint, balance, isMelt: true, lnurl: input }) + const decoded = decodeUrlOrAddress(input) + if (!decoded) { return openPromptAutoClose({ msg: 'Could not decode LNURL!' }) } + try { + const lnurlData = await getLnurlData(decoded) + if (!lnurlData) { return openPromptAutoClose({ msg: 'Could not fetch data from LNURL' }) } + return navigation.navigate('selectAmount', { mint, balance, isMelt: true, lnurl: { userInput: input, url: decoded, data: lnurlData } }) + } catch (e) { + return openPromptAutoClose({ msg: 'Could not fetch data from LNURL' }) + } } // not enough funds if (decodedAmount + estFee > balance) { diff --git a/src/screens/Payment/Send/SelectTarget.tsx b/src/screens/Payment/Send/SelectTarget.tsx index e7f4b454..3408e755 100644 --- a/src/screens/Payment/Send/SelectTarget.tsx +++ b/src/screens/Payment/Send/SelectTarget.tsx @@ -59,7 +59,7 @@ export default function SelectTargetScreen({ navigation, route }: TSelectTargetP mint, balance, isMelt: true, - lnurl: lud16 + lnurl: { userInput: lud16 } }) }} hasSeparator diff --git a/src/screens/QRScan/QRProcessing.tsx b/src/screens/QRScan/QRProcessing.tsx index 0eff8622..e65dbb43 100644 --- a/src/screens/QRScan/QRProcessing.tsx +++ b/src/screens/QRScan/QRProcessing.tsx @@ -7,6 +7,7 @@ import { preventBack } from '@nav/utils' import { useThemeContext } from '@src/context/Theme' import { NS } from '@src/i18n' import { isErr } from '@src/util' +import { getLnurlData } from '@src/util/lnurl' import { addToHistory } from '@store/latestHistoryEntries' import { getCustomMintNames } from '@store/mintStore' import { globals } from '@styles' @@ -19,7 +20,7 @@ import { ScaledSheet } from 'react-native-size-matters' export default function QRProcessingScreen({ navigation, route }: TQRProcessingPageProps) { const { t } = useTranslation([NS.mints]) const { color } = useThemeContext() - const { tokenInfo, token, ln } = route.params + const { tokenInfo, token, ln, lnurl, scanned } = route.params const getProcessingtxt = () => { if (token && tokenInfo) { return 'claiming' } @@ -56,13 +57,122 @@ export default function QRProcessingScreen({ navigation, route }: TQRProcessingP }) } + // TODO clean up code duplications + const handleLnurl = async () => { + if (!lnurl) { + return navigation.navigate('processingError', { + errorMsg: t('invoiceScanError', { ns: NS.error }), + scan: true + }) + } + try { + const lnurlData = await getLnurlData(lnurl?.url) + if (!lnurlData) { + return navigation.navigate('processingError', { + errorMsg: 'Could not fetch data from lnurl', + scan: true + }) + } + if (lnurlData.tag !== 'payRequest') { + return navigation.navigate('processingError', { + errorMsg: 'Only LNURL pay requests are currently supported', + scan: true + }) + } + if (lnurl?.mint && lnurl?.balance) { + return navigation.navigate('selectAmount', { + mint: lnurl?.mint, + balance: lnurl?.balance, + isMelt: true, + scanned, + lnurl: { + userInput: lnurl.data, + url: lnurl.url, + data: lnurlData + }, + }) + } + // user has not selected the mint yet (Pressed scan QR and scanned a Lightning invoice) + const mintsWithBal = await getMintsBalances() + const mints = await getCustomMintNames(mintsWithBal.map(m => ({ mintUrl: m.mintUrl }))) + const nonEmptyMint = mintsWithBal.filter(m => m.amount > 0) + // user has no funds + if (!nonEmptyMint.length) { + // user is redirected to the mint selection screen where he gets an appropriate message + return navigation.navigate('selectMint', { + mints, + mintsWithBal, + isMelt: true, + allMintsEmpty: true, + scanned, + lnurl: { + userInput: lnurl.data, + url: lnurl.url, + data: lnurlData + }, + }) + } + if (nonEmptyMint.length === 1 && nonEmptyMint[0].amount * 1000 < lnurlData.minSendable) { + return navigation.navigate('processingError', { + errorMsg: 'No enough funds for the minimum sendable amount', + scan: true + }) + } + // user has funds, select his first mint for the case that he has only one + if (nonEmptyMint.length === 1) { + if (nonEmptyMint[0].amount * 1000 < lnurlData.minSendable) { + return navigation.navigate('processingError', { + errorMsg: 'No enough funds for the minimum sendable amount', + scan: true + }) + } + return navigation.navigate('selectAmount', { + mint: nonEmptyMint[0], + balance: nonEmptyMint[0].amount, + isMelt: true, + scanned, + lnurl: { + userInput: lnurl.data, + url: lnurl.url, + data: lnurlData + }, + }) + } + if (mintsWithBal.some(m => m.amount * 1000 > lnurlData.minSendable)) { + // user needs to select mint from which he wants to pay + navigation.navigate('selectMint', { + mints, + mintsWithBal, + allMintsEmpty: !nonEmptyMint.length, + isMelt: true, + scanned, + lnurl: { + userInput: lnurl.data, + url: lnurl.url, + data: lnurlData + }, + }) + } else { + navigation.navigate('processingError', { + errorMsg: t('noFunds', { ns: NS.common }), + scan: true + }) + } + + } catch (e) { + navigation.navigate('processingError', { + errorMsg: isErr(e) ? e.message : 'Could not fetch data from lnurl', + scan: true + }) + } + } + const handleInvoice = async () => { if (!ln) { - navigation.navigate('processingError', { + return navigation.navigate('processingError', { errorMsg: t('invoiceScanError', { ns: NS.error }), scan: true }) - return } const { invoice, mint, balance, amount } = ln try { @@ -71,13 +181,12 @@ export default function QRProcessingScreen({ navigation, route }: TQRProcessingP // check if invoice amount is higher than the selected mint balance to avoid navigating const estFee = await checkFees(mint.mintUrl, invoice) if (amount + estFee > balance) { - navigation.navigate('processingError', { + return navigation.navigate('processingError', { errorMsg: t('noFundsForFee', { ns: NS.common, fee: estFee }), scan: true }) - return } - navigation.navigate('coinSelection', { + return navigation.navigate('coinSelection', { mint, balance, amount, @@ -86,7 +195,6 @@ export default function QRProcessingScreen({ navigation, route }: TQRProcessingP isMelt: true, scanned: true }) - return } // user has not selected the mint yet (Pressed scan QR and scanned a Lightning invoice) const mintsWithBal = await getMintsBalances() @@ -95,7 +203,7 @@ export default function QRProcessingScreen({ navigation, route }: TQRProcessingP // user has no funds if (!nonEmptyMint.length) { // user is redirected to the mint selection screen where he gets an appropriate message - navigation.navigate('selectMint', { + return navigation.navigate('selectMint', { mints, mintsWithBal, isMelt: true, @@ -104,28 +212,25 @@ export default function QRProcessingScreen({ navigation, route }: TQRProcessingP allMintsEmpty: true, scanned: true }) - return } // user has funds, select his first mint for the case that he has only one const mintUsing = mints.find(m => m.mintUrl === nonEmptyMint[0].mintUrl) || { mintUrl: 'N/A', customName: 'N/A' } const estFee = await checkFees(mintUsing.mintUrl, ln.invoice) if (nonEmptyMint.length === 1 && amount + estFee > nonEmptyMint[0].amount) { - navigation.navigate('processingError', { + return navigation.navigate('processingError', { errorMsg: t('noFundsForFee', { ns: NS.common, fee: estFee }), scan: true }) - return } // user has only 1 mint with enough balance, he can directly navigate to the payment overview page if (nonEmptyMint.length === 1) { if (nonEmptyMint[0].amount < amount + estFee) { - navigation.navigate('processingError', { + return navigation.navigate('processingError', { errorMsg: t('noFunds', { ns: NS.common }), scan: true }) - return } - navigation.navigate('coinSelection', { + return navigation.navigate('coinSelection', { mint: mintUsing, balance: nonEmptyMint[0].amount, amount, @@ -134,7 +239,6 @@ export default function QRProcessingScreen({ navigation, route }: TQRProcessingP isMelt: true, scanned: true }) - return } if (mintsWithBal.some(m => m.amount >= amount + estFee)) { // user needs to select mint from which he wants to pay the invoice @@ -165,8 +269,10 @@ export default function QRProcessingScreen({ navigation, route }: TQRProcessingP // start process useEffect(() => { if (token && tokenInfo) { - void receiveToken() - return + return void receiveToken() + } + if (lnurl) { + return void handleLnurl() } void handleInvoice() // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/screens/QRScan/index.tsx b/src/screens/QRScan/index.tsx index 467f70ae..63dfdf5f 100644 --- a/src/screens/QRScan/index.tsx +++ b/src/screens/QRScan/index.tsx @@ -3,7 +3,7 @@ import useLoading from '@comps/hooks/Loading' import useCashuToken from '@comps/hooks/Token' import { CloseIcon, FlashlightOffIcon } from '@comps/Icons' import { isIOS, QRType } from '@consts' -import { addMint, getMintsBalances, getMintsUrls } from '@db' +import { addMint, getMintsUrls } from '@db' import TrustMintModal from '@modal/TrustMint' import type { TQRScanPageProps } from '@model/nav' import { isNProfile, isNpubQR } from '@nostr/util' @@ -11,9 +11,10 @@ import { useIsFocused } from '@react-navigation/core' import { usePromptContext } from '@src/context/Prompt' import { useThemeContext } from '@src/context/Theme' import { NS } from '@src/i18n' -import { getCustomMintNames, getDefaultMint } from '@store/mintStore' +import { getDefaultMint } from '@store/mintStore' import { globals, mainColors } from '@styles' -import { decodeLnInvoice, extractStrFromURL, hasTrustedMint, isCashuToken, isLnurlOrAddress, isNull, isStr, isUrl } from '@util' +import { decodeLnInvoice, extractStrFromURL, hasTrustedMint, isCashuToken, isNull, isStr } from '@util' +import { decodeUrlOrAddress, isLnurlOrAddress, isUrl } from '@util/lnurl' import { getTokenInfo } from '@wallet/proofs' import { BarCodeScanner, PermissionStatus } from 'expo-barcode-scanner' import { Camera, FlashMode } from 'expo-camera' @@ -82,7 +83,7 @@ export default function QRScanPage({ navigation, route }: TQRScanPageProps) { navigation.navigate('qr processing', { tokenInfo, token }) } - const handleBarCodeScanned = async ({ type, data }: { type: string, data: string }) => { + const handleBarCodeScanned = ({ type, data }: { type: string, data: string }) => { setScanned(true) const bcType = isIOS ? 'org.iso.QRCode' : +QRType // early return if barcode is not a QR @@ -114,27 +115,13 @@ export default function QRScanPage({ navigation, route }: TQRScanPageProps) { return navigation.navigate('mint confirm', { mintUrl: data }) } // handle LNURL - - if (isLnurlOrAddress(data) ) { - - if (mint === undefined || balance === undefined) { - - // user has not selected the mint yet (Pressed scan QR and scanned a Lightning invoice) - const mintsWithBal = await getMintsBalances() - const mints = await getCustomMintNames(mintsWithBal.map(m => ({ mintUrl: m.mintUrl }))) - const nonEmptyMint = mintsWithBal.filter(m => m.amount > 0) - const mintUsing = mints.find(m => m.mintUrl === nonEmptyMint[0].mintUrl) || { mintUrl: 'N/A', customName: 'N/A' } - - return navigation.navigate('selectAmount', { mint:mintUsing, balance:nonEmptyMint[0].amount, isMelt: true, lnurl: data }) - - - } - - return navigation.navigate('selectAmount', { mint, balance, isMelt: true, lnurl: data }) - - + if (isLnurlOrAddress(data)) { + const decoded = decodeUrlOrAddress(data) + if (!decoded) { + return openPromptAutoClose({ msg: t('unknownType') + ` - decoded LNURL: "${decoded}"` }) + } + return navigation.navigate('qr processing', { lnurl: { data, mint, balance, url: decoded }, scanned: true }) } - // handle LN invoice try { const invoice = extractStrFromURL(data) || data diff --git a/src/util/index.ts b/src/util/index.ts index 6b9945d9..56fe14cd 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -2,28 +2,14 @@ import { decodeInvoice, getDecodedToken } from '@cashu/cashu-ts' import { l } from '@log' import type { ILnUrl, IMintBalWithName, IProofSelection } from '@model' import { IContact } from '@src/model/nostr' -import { bech32 } from 'bech32' import { Buffer } from 'buffer/' import * as Clipboard from 'expo-clipboard' import { Linking, Share, Vibration } from 'react-native' +import { decodeUrlOrAddress, isLnurlOrAddress, isUrl } from './lnurl' import { getLanguageCode } from './localization' import { isArr, isStr } from './typeguards' -const LNURL_REGEX = - /^(?:http.*[&?]lightning=|lightning:)?(lnurl[0-9]{1,}[02-9ac-hj-np-z]+)/ - -const LN_ADDRESS_REGEX = - /^((?:[^<>()[\]\\.,;:\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,}))$/ - -const LNURLP_REGEX = - /^lnurlp:\/\/([\w-]+\.)+[\w-]+(:\d{1,5})?(\/[\w-./?%&=]*)?$/ - -export interface LightningAddress { - username: string - domain: string -} - export { isArr, isArrOf, isArrOfNonNullable, isArrOfNum, isArrOfObj, isArrOfStr, isBool, isBuf, isErr, isFunc, isNonNullable, isNull, isNum, isObj, isStr, isUndef } from './typeguards' export function unixTimestamp() { return Math.ceil(new Date().getTime() / 1000) } @@ -109,11 +95,6 @@ export function getHistoryGroupDate(date: Date) { return isToday(date) ? 'Today' : getShortDateStr(date) } -export function isUrl(url: string) { - try { return !!new URL(url) } catch { /* ignore*/ } - return false -} - export function formatMintUrl(url: string) { const clean = url.startsWith('http') ? url.split('://')[1] : url if (clean.length < 30) { return clean } @@ -139,110 +120,6 @@ export function vib(pattern?: number | number[]) { Vibration.vibrate(pattern) } -export function isLnurlOrAddress(lnUrlOrAddress: string) { - - const address = parseLightningAddress(lnUrlOrAddress) - if (address) { - const { username, domain } = address - const protocol = domain.match(/\.onion$/) ? 'http' : 'https' - return isUrl(`${protocol}://${domain}/.well-known/lnurlp/${username}`) - } - - const bech32Url:string | null = parseLnUrl(lnUrlOrAddress) - if (bech32Url) {return true} - - const lnurlp = parseLnurlp(lnUrlOrAddress) - if (lnurlp) {return true} - - return false - -} - -/** - * Parse an url and return a bech32 encoded url (lnurl) - * @method parseLnUrl - * @param url string to parse - * @return bech32 encoded url (lnurl) or null if is an invalid url - */ -export const parseLnUrl = (url: string): string | null => { - if (!url) {return null} - const result = LNURL_REGEX.exec(url.toLowerCase()) - return result ? result[1] : null -} - - -/** - * Verify if a string is a lightning adress - * @method isLightningAddress - * @param address string to validate - * @return true if is a lightning address - */ -export const isLightningAddress = (address: string): boolean => { - if (!address) {return false} - return LN_ADDRESS_REGEX.test(address) -} - -/** - * Parse an address and return username and domain - * @method parseLightningAddress - * @param address string to parse - * @return LightningAddress { username, domain } - */ -export const parseLightningAddress = ( - address: string -): LightningAddress | null => { - if (!address) {return null} - const result = LN_ADDRESS_REGEX.exec(address) - return result ? { username: result[1], domain: result[2] } : null -} - -/** - * Verify if a string is a lnurlp url - * @method isLnurlp - * @param url string to validate - * @return true if is a lnurlp url - */ -export const isLnurlp = (url: string): boolean => { - if (!url) {return false} - return LNURLP_REGEX.test(url) -} - -/** - * Parse a lnurlp url and return an url with the proper protocol - * @method parseLnurlp - * @param url string to parse - * @return url (http or https) or null if is an invalid lnurlp - */ -export const parseLnurlp = (url: string): string | null => { - if (!url) {return null} - - const parsedUrl = url.toLowerCase() - if (!LNURLP_REGEX.test(parsedUrl)) {return null} - - const protocol = parsedUrl.includes('.onion') ? 'http://' : 'https://' - return parsedUrl.replace('lnurlp://', protocol) -} - -export const decodeUrlOrAddress = (lnUrlOrAddress: string): string | null => { - - const bech32Url = parseLnUrl(lnUrlOrAddress) - if (bech32Url) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access - const decoded = bech32.decode(bech32Url, 20000) - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - return Buffer.from(bech32.fromWords(decoded.words)).toString() - } - - const address = parseLightningAddress(lnUrlOrAddress) - if (address) { - const { username, domain } = address - const protocol = domain.match(/\.onion$/) ? 'http' : 'https' - return `${protocol}://${domain}/.well-known/lnurlp/${username}` - } - - return parseLnurlp(lnUrlOrAddress) -} - export function hasTrustedMint(userMints: string[], tokenMints: string[]): boolean export function hasTrustedMint(userMints: { mintUrl: string }[], tokenMints: string[]): boolean @@ -257,12 +134,11 @@ export async function getInvoiceFromLnurl(lnUrlOrAddress: string, amount: number lnUrlOrAddress = lnTrim(lnUrlOrAddress) if (!isLnurlOrAddress(lnUrlOrAddress)) { throw new Error('invalid address') } const url = decodeUrlOrAddress(lnUrlOrAddress) - if (!url || !isUrl(url)) {throw new Error('Invalid lnUrlOrAddress')} + if (!url || !isUrl(url)) { throw new Error('Invalid lnUrlOrAddress') } amount *= 1000 const resp = await fetch(url) const { tag, callback, minSendable, maxSendable } = await resp.json() // const { tag, callback, minSendable, maxSendable } = await (await fetch(`https://${host}/.well-known/lnurlp/${user}`)).json() - if (tag === 'payRequest' && minSendable <= amount && amount <= maxSendable) { const resp = await fetch(`${callback}?amount=${amount}`) const { pr } = await resp.json<{ pr: string }>() @@ -295,7 +171,7 @@ export function isCashuToken(token: string) { return token.trim() } -export function lnTrim(str:string) { +export function lnTrim(str: string) { if (!str || !isStr(str)) { return '' } str = str.trim().toLowerCase() const uriPrefixes = [ @@ -314,8 +190,6 @@ export function lnTrim(str:string) { str = str.slice(prefix.length).trim() }) return str.trim() - - } export function isLnInvoice(str: string) { diff --git a/src/util/lnurl.ts b/src/util/lnurl.ts new file mode 100644 index 00000000..d22cfe55 --- /dev/null +++ b/src/util/lnurl.ts @@ -0,0 +1,136 @@ +import type { ILnUrlPayRequest } from '@model' +import { bech32 } from 'bech32' +import { Buffer } from 'buffer/' + +const LNURL_REGEX = + /^(?:http.*[&?]lightning=|lightning:)?(lnurl[0-9]{1,}[02-9ac-hj-np-z]+)/ + +const LN_ADDRESS_REGEX = + /^((?:[^<>()[\]\\.,;:\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,}))$/ + +const LNURLP_REGEX = + /^lnurlp:\/\/([\w-]+\.)+[\w-]+(:\d{1,5})?(\/[\w-./?%&=]*)?$/ + +export interface LightningAddress { + username: string + domain: string +} + +export function isUrl(url: string) { + try { return !!new URL(url) } catch { /* ignore*/ } + return false +} + +export function isLnurlOrAddress(lnUrlOrAddress: string) { + return (isLnurl(lnUrlOrAddress) || isLnurlAddress(lnUrlOrAddress)) +} + +export function isLnurlAddress(str: string) { + const address = parseLightningAddress(str) + if (address) { + const { username, domain } = address + const protocol = domain.endsWith('.onion') ? 'http' : 'https' + return isUrl(`${protocol}://${domain}/.well-known/lnurlp/${username}`) + } + return false +} + +export function isLnurl(str: string) { + const bech32Url: string | null = parseLnUrl(str) + if (bech32Url) { return true } + const lnurlp = parseLnurlp(str) + if (lnurlp) { return true } + return false +} + +/** + * Parse an url and return a bech32 encoded url (lnurl) + * @method parseLnUrl + * @param url string to parse + * @return bech32 encoded url (lnurl) or null if is an invalid url + */ +export const parseLnUrl = (url: string): string | null => { + if (!url) { return null } + const result = LNURL_REGEX.exec(url.toLowerCase()) + return result ? result[1] : null +} + +/** + * Verify if a string is a lightning adress + * @method isLightningAddress + * @param address string to validate + * @return true if is a lightning address + */ +export const isLightningAddress = (address: string): boolean => { + if (!address) { return false } + return LN_ADDRESS_REGEX.test(address) +} + +/** + * Parse an address and return username and domain + * @method parseLightningAddress + * @param address string to parse + * @return LightningAddress { username, domain } + */ +export const parseLightningAddress = ( + address: string +): LightningAddress | null => { + if (!address) { return null } + const result = LN_ADDRESS_REGEX.exec(address) + return result ? { username: result[1], domain: result[2] } : null +} + +/** + * Verify if a string is a lnurlp url + * @method isLnurlp + * @param url string to validate + * @return true if is a lnurlp url + */ +export const isLnurlp = (url: string): boolean => { + if (!url) { return false } + return LNURLP_REGEX.test(url) +} + +/** + * Parse a lnurlp url and return an url with the proper protocol + * @method parseLnurlp + * @param url string to parse + * @return url (http or https) or null if is an invalid lnurlp + */ +export const parseLnurlp = (url: string): string | null => { + if (!url) { return null } + const parsedUrl = url.toLowerCase() + if (!LNURLP_REGEX.test(parsedUrl)) { return null } + const protocol = parsedUrl.includes('.onion') ? 'http://' : 'https://' + return parsedUrl.replace('lnurlp://', protocol) +} + +export const decodeUrlOrAddress = (lnUrlOrAddress: string): string | null => { + const bech32Url = parseLnUrl(lnUrlOrAddress) + if (bech32Url) { + const decoded = bech32.decode(bech32Url, 20000) + return Buffer.from(bech32.fromWords(decoded.words)).toString() + } + const address = parseLightningAddress(lnUrlOrAddress) + if (address) { + const { username, domain } = address + const protocol = domain.match(/\.onion$/) ? 'http' : 'https' + return `${protocol}://${domain}/.well-known/lnurlp/${username}` + } + return parseLnurlp(lnUrlOrAddress) +} + +export function getLnurlData(url?: string): Promise | null { + if (!url) { return null } + return fetch(url).then(res => res.json()) +} + +export function getLnurlIdentifierFromMetadata(metadata: string) { + try { + const parsed = JSON.parse(metadata) as string[][] + const identidier = parsed.find(([key]) => key === 'text/identifier')?.[1] + return identidier ?? 'Identifier not found' + } catch (e) { + return 'Error: Identifier not found' + } +} \ No newline at end of file