From 9e1f2488b35a5501ccb09ad9130ca4099e4f3e00 Mon Sep 17 00:00:00 2001 From: First-Terraner Date: Mon, 25 Mar 2024 10:51:03 +0100 Subject: [PATCH 1/9] add lnurl.ts & lint --- src/screens/QRScan/index.tsx | 34 +++++----- src/util/index.ts | 127 +---------------------------------- src/util/lnurl.ts | 108 +++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 140 deletions(-) create mode 100644 src/util/lnurl.ts diff --git a/src/screens/QRScan/index.tsx b/src/screens/QRScan/index.tsx index 467f70ae..c9a98ed3 100644 --- a/src/screens/QRScan/index.tsx +++ b/src/screens/QRScan/index.tsx @@ -13,7 +13,8 @@ import { useThemeContext } from '@src/context/Theme' import { NS } from '@src/i18n' import { getCustomMintNames, 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, isUrl } from '@util' +import { isLnurlOrAddress } from '@util/lnurl' import { getTokenInfo } from '@wallet/proofs' import { BarCodeScanner, PermissionStatus } from 'expo-barcode-scanner' import { Camera, FlashMode } from 'expo-camera' @@ -114,27 +115,28 @@ export default function QRScanPage({ navigation, route }: TQRScanPageProps) { return navigation.navigate('mint confirm', { mintUrl: data }) } // handle LNURL - - if (isLnurlOrAddress(data) ) { - - if (mint === undefined || balance === undefined) { - + if (isLnurlOrAddress(data)) { + if (!mint || !balance) { // 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: true + }) + } 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 }) - - + return navigation.navigate('selectAmount', { mint: mintUsing, balance: nonEmptyMint[0].amount, isMelt: true, lnurl: data }) + } + return navigation.navigate('selectAmount', { mint, balance, isMelt: true, lnurl: data }) } - // handle LN invoice try { const invoice = extractStrFromURL(data) || data diff --git a/src/util/index.ts b/src/util/index.ts index 6b9945d9..083e01c3 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 } 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) } @@ -139,110 +125,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 +139,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 +176,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 +195,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..a69377a7 --- /dev/null +++ b/src/util/lnurl.ts @@ -0,0 +1,108 @@ +import { bech32 } from 'bech32' + +import { isUrl } from '.' + +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 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) { + 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) +} \ No newline at end of file From 91d257025179fe7ff4750630d0e81552a70721df Mon Sep 17 00:00:00 2001 From: First-Terraner Date: Mon, 25 Mar 2024 11:56:28 +0100 Subject: [PATCH 2/9] show lnurl address in amount selection screen --- src/screens/Payment/SelectAmount.tsx | 7 ++++ src/screens/Payment/Send/Inputfield.tsx | 3 +- src/screens/QRScan/index.tsx | 54 +++++++++++++++---------- src/util/lnurl.ts | 9 +++++ 4 files changed, 50 insertions(+), 23 deletions(-) diff --git a/src/screens/Payment/SelectAmount.tsx b/src/screens/Payment/SelectAmount.tsx index fc2f3021..71eb31ff 100644 --- a/src/screens/Payment/SelectAmount.tsx +++ b/src/screens/Payment/SelectAmount.tsx @@ -181,6 +181,13 @@ export default function SelectAmountScreen({ navigation, route }: TSelectAmountP /> } + {lnurl && lnurl.length > 0 && + + } { + const address = decodeUrlOrAddress(data) + if (!address) { + // TODO add error message + return + } + const lnurl = extractLnurlAddress(address) + // user has not selected the mint yet (Immediatly scanned a QR code) + if (!mint || !balance) { + 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: true + }) + } + 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 }) + } + return navigation.navigate('selectAmount', { mint, balance, isMelt: true, lnurl }) + } + const handleTrustModal = async () => { if (loading) { return } startLoading() @@ -83,7 +112,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 @@ -116,26 +145,7 @@ export default function QRScanPage({ navigation, route }: TQRScanPageProps) { } // handle LNURL if (isLnurlOrAddress(data)) { - if (!mint || !balance) { - // 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: true - }) - } - 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 }) + return handleLnurlAddress(data) } // handle LN invoice try { diff --git a/src/util/lnurl.ts b/src/util/lnurl.ts index a69377a7..034fc645 100644 --- a/src/util/lnurl.ts +++ b/src/util/lnurl.ts @@ -1,4 +1,5 @@ import { bech32 } from 'bech32' +import { Buffer } from 'buffer/' import { isUrl } from '.' @@ -105,4 +106,12 @@ export const decodeUrlOrAddress = (lnUrlOrAddress: string): string | null => { return `${protocol}://${domain}/.well-known/lnurlp/${username}` } return parseLnurlp(lnUrlOrAddress) +} + +export function extractLnurlAddress(url: string): string { + const urlObj = new URL(url) + const domain = urlObj.hostname + const pathSegments = urlObj.pathname.split('/') + const username = pathSegments[pathSegments.length - 1] + return `${username}@${domain}` } \ No newline at end of file From addb906c3c913d332b686e363f0b6e6e7f01b1d5 Mon Sep 17 00:00:00 2001 From: First-Terraner Date: Tue, 26 Mar 2024 15:23:35 +0100 Subject: [PATCH 3/9] update UI & rename functions --- src/model/index.ts | 9 ++ src/model/nav.ts | 17 ++- src/screens/Addressbook/index.tsx | 3 +- src/screens/Payment/Processing.tsx | 5 +- src/screens/Payment/SelectAmount.tsx | 34 +++-- src/screens/Payment/Send/CoinSelection.tsx | 5 +- src/screens/Payment/Send/Inputfield.tsx | 16 ++- src/screens/Payment/Send/SelectTarget.tsx | 2 +- src/screens/QRScan/QRProcessing.tsx | 138 ++++++++++++++++++--- src/screens/QRScan/index.tsx | 41 ++---- src/util/lnurl.ts | 35 ++++-- 11 files changed, 221 insertions(+), 84 deletions(-) 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..32776652 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,7 +81,7 @@ export type RootStackParamList = { nostr?: INostrSendData isSwap?: boolean balance: number - lnurl?: string + lnurl?: ILnurlNavData targetMint?: IMintUrl } selectNostrAmount: { @@ -124,6 +131,12 @@ export type RootStackParamList = { balance?: number amount: number } + lnurl?: { + mint?: IMintUrl + balance?: number + url: string + data: string + } } 'mint confirm': { mintUrl: string 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 71eb31ff..63dd1e25 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' @@ -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 @@ -181,11 +183,19 @@ export default function SelectAmountScreen({ navigation, route }: TSelectAmountP /> } - {lnurl && lnurl.length > 0 && + {lnurl && (lnurl.data || lnurl.userInput) && } diff --git a/src/screens/Payment/Send/CoinSelection.tsx b/src/screens/Payment/Send/CoinSelection.tsx index ae9b678c..b4cc0b99 100644 --- a/src/screens/Payment/Send/CoinSelection.tsx +++ b/src/screens/Payment/Send/CoinSelection.tsx @@ -12,7 +12,8 @@ import { useInitialURL } from '@src/context/Linking' import { useThemeContext } from '@src/context/Theme' import { NS } from '@src/i18n' import { globals } from '@styles' -import { formatInt, formatMintUrl, formatSatStr, getSelectedAmount, isLnurlOrAddress, isNum } from '@util' +import { formatInt, formatMintUrl, formatSatStr, getSelectedAmount, isNum } from '@util' +import { isLightningAddress } from '@util/lnurl' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { ScrollView, View } from 'react-native' @@ -60,7 +61,7 @@ export default function CoinSelectionScreen({ navigation, route }: TCoinSelectio const getRecipient = () => { 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 14752cc6..298adb0b 100644 --- a/src/screens/Payment/Send/Inputfield.tsx +++ b/src/screens/Payment/Send/Inputfield.tsx @@ -11,7 +11,7 @@ import { useThemeContext } from '@src/context/Theme' import { NS } from '@src/i18n' import { globals } from '@styles' import { decodeLnInvoice, getStrFromClipboard, isErr, openUrl } from '@util' -import { isLnurlOrAddress } from '@util/lnurl' +import { decodeUrlOrAddress, getLnurlData, isLnurlOrAddress } from '@util/lnurl' import { checkFees } from '@wallet' import { createRef, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -66,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..6b1dca6a 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 } = route.params const getProcessingtxt = () => { if (token && tokenInfo) { return 'claiming' } @@ -56,13 +57,120 @@ 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, + 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: true, + 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, + 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: true, + 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 +179,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 +193,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 +201,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 +210,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 +237,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 +267,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 2b2bba33..9042d773 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,10 +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, isNull, isStr, isUrl } from '@util' -import { decodeUrlOrAddress, extractLnurlAddress, isLnurlOrAddress } from '@util/lnurl' +import { decodeUrlOrAddress, isLnurlOrAddress } from '@util/lnurl' import { getTokenInfo } from '@wallet/proofs' import { BarCodeScanner, PermissionStatus } from 'expo-barcode-scanner' import { Camera, FlashMode } from 'expo-camera' @@ -64,35 +64,6 @@ export default function QRScanPage({ navigation, route }: TQRScanPageProps) { navigation.navigate('qr processing', { tokenInfo: info, token: data }) } - const handleLnurlAddress = async (data: string) => { - const address = decodeUrlOrAddress(data) - if (!address) { - // TODO add error message - return - } - const lnurl = extractLnurlAddress(address) - // user has not selected the mint yet (Immediatly scanned a QR code) - if (!mint || !balance) { - 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: true - }) - } - 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 }) - } - return navigation.navigate('selectAmount', { mint, balance, isMelt: true, lnurl }) - } - const handleTrustModal = async () => { if (loading) { return } startLoading() @@ -145,7 +116,11 @@ export default function QRScanPage({ navigation, route }: TQRScanPageProps) { } // handle LNURL if (isLnurlOrAddress(data)) { - return handleLnurlAddress(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 } }) } // handle LN invoice try { diff --git a/src/util/lnurl.ts b/src/util/lnurl.ts index 034fc645..d0cf80f7 100644 --- a/src/util/lnurl.ts +++ b/src/util/lnurl.ts @@ -1,3 +1,5 @@ +import type { ILnUrlPayRequest } from '@model' +import { cTo } from '@store/utils' import { bech32 } from 'bech32' import { Buffer } from 'buffer/' @@ -18,15 +20,23 @@ export interface LightningAddress { } export function isLnurlOrAddress(lnUrlOrAddress: string) { - const address = parseLightningAddress(lnUrlOrAddress) + return (isLnurl(lnUrlOrAddress) || isLnurlAddress(lnUrlOrAddress)) +} + +export function isLnurlAddress(str: string) { + const address = parseLightningAddress(str) 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) + return false +} + +export function isLnurl(str: string) { + const bech32Url: string | null = parseLnUrl(str) if (bech32Url) { return true } - const lnurlp = parseLnurlp(lnUrlOrAddress) + const lnurlp = parseLnurlp(str) if (lnurlp) { return true } return false } @@ -108,10 +118,17 @@ export const decodeUrlOrAddress = (lnUrlOrAddress: string): string | null => { return parseLnurlp(lnUrlOrAddress) } -export function extractLnurlAddress(url: string): string { - const urlObj = new URL(url) - const domain = urlObj.hostname - const pathSegments = urlObj.pathname.split('/') - const username = pathSegments[pathSegments.length - 1] - return `${username}@${domain}` +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 = cTo(metadata) + 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 From 7f7c7d0af5bd2603f006ce0af9d67cd0fdde14ff Mon Sep 17 00:00:00 2001 From: KKA11010 Date: Tue, 26 Mar 2024 19:27:54 +0100 Subject: [PATCH 4/9] fix require cycle. replace onion regex with string.endsWith() --- src/nostr/consts.ts | 4 ++-- src/util/index.ts | 7 +------ src/util/lnurl.ts | 12 +++++++----- 3 files changed, 10 insertions(+), 13 deletions(-) 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/util/index.ts b/src/util/index.ts index 083e01c3..56fe14cd 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -6,7 +6,7 @@ import { Buffer } from 'buffer/' import * as Clipboard from 'expo-clipboard' import { Linking, Share, Vibration } from 'react-native' -import { decodeUrlOrAddress, isLnurlOrAddress } from './lnurl' +import { decodeUrlOrAddress, isLnurlOrAddress, isUrl } from './lnurl' import { getLanguageCode } from './localization' import { isArr, isStr } from './typeguards' @@ -95,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 } diff --git a/src/util/lnurl.ts b/src/util/lnurl.ts index d0cf80f7..d22cfe55 100644 --- a/src/util/lnurl.ts +++ b/src/util/lnurl.ts @@ -1,10 +1,7 @@ import type { ILnUrlPayRequest } from '@model' -import { cTo } from '@store/utils' import { bech32 } from 'bech32' import { Buffer } from 'buffer/' -import { isUrl } from '.' - const LNURL_REGEX = /^(?:http.*[&?]lightning=|lightning:)?(lnurl[0-9]{1,}[02-9ac-hj-np-z]+)/ @@ -19,6 +16,11 @@ export interface LightningAddress { 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)) } @@ -27,7 +29,7 @@ export function isLnurlAddress(str: string) { const address = parseLightningAddress(str) if (address) { const { username, domain } = address - const protocol = domain.match(/\.onion$/) ? 'http' : 'https' + const protocol = domain.endsWith('.onion') ? 'http' : 'https' return isUrl(`${protocol}://${domain}/.well-known/lnurlp/${username}`) } return false @@ -125,7 +127,7 @@ export function getLnurlData(url?: string): Promise | null { export function getLnurlIdentifierFromMetadata(metadata: string) { try { - const parsed = cTo(metadata) + const parsed = JSON.parse(metadata) as string[][] const identidier = parsed.find(([key]) => key === 'text/identifier')?.[1] return identidier ?? 'Identifier not found' } catch (e) { From fe5b964f8e2ec644441e40dfd992621470163e06 Mon Sep 17 00:00:00 2001 From: KKA11010 Date: Tue, 26 Mar 2024 19:37:25 +0100 Subject: [PATCH 5/9] fix ci --- src/screens/QRScan/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/screens/QRScan/index.tsx b/src/screens/QRScan/index.tsx index 9042d773..953c9a22 100644 --- a/src/screens/QRScan/index.tsx +++ b/src/screens/QRScan/index.tsx @@ -13,8 +13,8 @@ import { useThemeContext } from '@src/context/Theme' import { NS } from '@src/i18n' import { getDefaultMint } from '@store/mintStore' import { globals, mainColors } from '@styles' -import { decodeLnInvoice, extractStrFromURL, hasTrustedMint, isCashuToken, isNull, isStr, isUrl } from '@util' -import { decodeUrlOrAddress, isLnurlOrAddress } from '@util/lnurl' +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' From bc9d577dec32d70fe30f94f0cf6c0efd968a00bc Mon Sep 17 00:00:00 2001 From: First-Terraner Date: Wed, 27 Mar 2024 22:03:58 +0100 Subject: [PATCH 6/9] remove node 16.x from CI workflow --- .github/workflows/node.js.yml | 2 +- package-lock.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index d93ec997..0b03cf45 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] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: 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", From 0eda380a0c4fec75a5da0c78fb3f3fe085507e47 Mon Sep 17 00:00:00 2001 From: First-Terraner Date: Wed, 27 Mar 2024 22:06:54 +0100 Subject: [PATCH 7/9] Add support for Node.js 21.x --- .github/workflows/node.js.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 0b03cf45..9c8a4e64 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -19,7 +19,7 @@ jobs: strategy: matrix: - node-version: [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: From 987a7825ed8db14c513423b9fa1b1fd36abe301f Mon Sep 17 00:00:00 2001 From: First-Terraner Date: Wed, 27 Mar 2024 22:29:48 +0100 Subject: [PATCH 8/9] fix going back from amount selection screen --- src/model/nav.ts | 2 ++ src/screens/Payment/SelectAmount.tsx | 4 ++-- src/screens/QRScan/QRProcessing.tsx | 8 +++++--- src/screens/QRScan/index.tsx | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/model/nav.ts b/src/model/nav.ts index 32776652..a4d7c007 100644 --- a/src/model/nav.ts +++ b/src/model/nav.ts @@ -83,6 +83,7 @@ export type RootStackParamList = { balance: number lnurl?: ILnurlNavData targetMint?: IMintUrl + scanned?: boolean } selectNostrAmount: { mint: IMintUrl @@ -125,6 +126,7 @@ export type RootStackParamList = { 'qr processing': { tokenInfo?: ITokenInfo token?: string + scanned?: boolean ln?: { invoice: string mint?: IMintUrl diff --git a/src/screens/Payment/SelectAmount.tsx b/src/screens/Payment/SelectAmount.tsx index 63dd1e25..47132420 100644 --- a/src/screens/Payment/SelectAmount.tsx +++ b/src/screens/Payment/SelectAmount.tsx @@ -22,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() @@ -171,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}`)} diff --git a/src/screens/QRScan/QRProcessing.tsx b/src/screens/QRScan/QRProcessing.tsx index 6b1dca6a..e65dbb43 100644 --- a/src/screens/QRScan/QRProcessing.tsx +++ b/src/screens/QRScan/QRProcessing.tsx @@ -20,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, lnurl } = route.params + const { tokenInfo, token, ln, lnurl, scanned } = route.params const getProcessingtxt = () => { if (token && tokenInfo) { return 'claiming' } @@ -84,6 +84,7 @@ export default function QRProcessingScreen({ navigation, route }: TQRProcessingP mint: lnurl?.mint, balance: lnurl?.balance, isMelt: true, + scanned, lnurl: { userInput: lnurl.data, url: lnurl.url, @@ -103,7 +104,7 @@ export default function QRProcessingScreen({ navigation, route }: TQRProcessingP mintsWithBal, isMelt: true, allMintsEmpty: true, - scanned: true, + scanned, lnurl: { userInput: lnurl.data, url: lnurl.url, @@ -129,6 +130,7 @@ export default function QRProcessingScreen({ navigation, route }: TQRProcessingP mint: nonEmptyMint[0], balance: nonEmptyMint[0].amount, isMelt: true, + scanned, lnurl: { userInput: lnurl.data, url: lnurl.url, @@ -143,7 +145,7 @@ export default function QRProcessingScreen({ navigation, route }: TQRProcessingP mintsWithBal, allMintsEmpty: !nonEmptyMint.length, isMelt: true, - scanned: true, + scanned, lnurl: { userInput: lnurl.data, url: lnurl.url, diff --git a/src/screens/QRScan/index.tsx b/src/screens/QRScan/index.tsx index 953c9a22..63dfdf5f 100644 --- a/src/screens/QRScan/index.tsx +++ b/src/screens/QRScan/index.tsx @@ -120,7 +120,7 @@ export default function QRScanPage({ navigation, route }: TQRScanPageProps) { if (!decoded) { return openPromptAutoClose({ msg: t('unknownType') + ` - decoded LNURL: "${decoded}"` }) } - return navigation.navigate('qr processing', { lnurl: { data, mint, balance, url: decoded } }) + return navigation.navigate('qr processing', { lnurl: { data, mint, balance, url: decoded }, scanned: true }) } // handle LN invoice try { From a3e2356bc283c3d2fd5bafcbda93adb1d3c384be Mon Sep 17 00:00:00 2001 From: First-Terraner Date: Wed, 27 Mar 2024 22:36:20 +0100 Subject: [PATCH 9/9] update translations --- 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 +- 6 files changed, 6 insertions(+), 6 deletions(-) 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",