diff --git a/app/(app)/send/manual.js b/app/(app)/send/manual.js new file mode 100644 index 00000000..64b24a30 --- /dev/null +++ b/app/(app)/send/manual.js @@ -0,0 +1,5 @@ +import { Manual } from "../../../pages/send/Manual"; + +export default function Page() { + return ; +} diff --git a/components/Icons.tsx b/components/Icons.tsx index e094bda1..e6c84581 100644 --- a/components/Icons.tsx +++ b/components/Icons.tsx @@ -13,6 +13,7 @@ import { PopiconsUploadSolid as ExportIcon, PopiconsTouchIdSolid as FingerprintIcon, PopiconsCircleInfoSolid as HelpCircleIcon, + PopiconsKeyboardSolid as KeyboardIcon, PopiconsLinkExternalSolid as LinkIcon, PopiconsArrowDownLine as MoveDownIcon, PopiconsArrowUpLine as MoveUpIcon, @@ -65,6 +66,7 @@ interopIcon(EditIcon); interopIcon(ExportIcon); interopIcon(FingerprintIcon); interopIcon(HelpCircleIcon); +interopIcon(KeyboardIcon); interopIcon(LinkIcon); interopIcon(MoveDownIcon); interopIcon(MoveUpIcon); @@ -103,6 +105,7 @@ export { ExportIcon, FingerprintIcon, HelpCircleIcon, + KeyboardIcon, LinkIcon, MoveDownIcon, MoveUpIcon, diff --git a/components/Receiver.tsx b/components/Receiver.tsx index 7a4287c7..0edcfe62 100644 --- a/components/Receiver.tsx +++ b/components/Receiver.tsx @@ -3,14 +3,14 @@ import { View } from "react-native"; import { Text } from "~/components/ui/text"; interface ReceiverProps { - originalText: string; + name: string; invoice?: string; } -export function Receiver({ originalText, invoice }: ReceiverProps) { +export function Receiver({ name, invoice }: ReceiverProps) { const shouldShowReceiver = - originalText !== invoice && - originalText.toLowerCase().replace("lightning:", "").includes("@"); + name !== invoice && + name.toLowerCase().replace("lightning:", "").includes("@"); if (!shouldShowReceiver) { return null; @@ -22,7 +22,7 @@ export function Receiver({ originalText, invoice }: ReceiverProps) { To - {originalText.toLowerCase().replace("lightning:", "")} + {name.toLowerCase().replace("lightning:", "")} ); diff --git a/hooks/__tests__/useHandleLinking.ts b/hooks/__tests__/useHandleLinking.ts index f957fbe7..c1ed068c 100644 --- a/hooks/__tests__/useHandleLinking.ts +++ b/hooks/__tests__/useHandleLinking.ts @@ -3,20 +3,38 @@ import { handleLink } from "../../lib/link"; jest.mock("expo-router"); +const mockLNURLPayResponse = { + tag: "payRequest", + callback: "https://getalby.com/callback", + commentAllowed: 255, + minSendable: 1000, + maxSendable: 10000000, + payerData: { + name: { mandatory: false }, + email: { mandatory: false }, + pubkey: { mandatory: false }, + }, +}; + +const mockLNURLWithdrawResponse = { + tag: "withdrawRequest", + callback: "https://getalby.com/callback", + k1: "unused", + defaultDescription: "withdrawal", + minWithdrawable: 21000, + maxWithdrawable: 21000, +}; + // Mock the lnurl module jest.mock("../../lib/lnurl", () => { const originalModule = jest.requireActual("../../lib/lnurl"); const mockGetDetails = jest.fn(async (lnurlString) => { + if (lnurlString === "hello@getalby.com") { + return mockLNURLPayResponse; + } if (lnurlString.startsWith("lnurlw")) { - return { - tag: "withdrawRequest", - callback: "https://getalby.com/callback", - k1: "unused", - defaultDescription: "withdrawal", - minWithdrawable: 21000, - maxWithdrawable: 21000, - }; + return mockLNURLWithdrawResponse; } return originalModule.lnurl.getDetails(lnurlString); }); @@ -30,53 +48,65 @@ jest.mock("../../lib/lnurl", () => { }; }); -const testVectors: Record = { +const testVectors: Record = { // Lightning Addresses "lightning:hello@getalby.com": { - url: "hello@getalby.com", - path: "/send", + path: "/send/lnurl-pay", + params: { + lnurlDetailsJSON: JSON.stringify(mockLNURLPayResponse), + receiver: "hello@getalby.com", + }, }, "lightning://hello@getalby.com": { - url: "hello@getalby.com", - path: "/send", + path: "/send/lnurl-pay", + params: { + lnurlDetailsJSON: JSON.stringify(mockLNURLPayResponse), + receiver: "hello@getalby.com", + }, }, "LIGHTNING://hello@getalby.com": { - url: "hello@getalby.com", - path: "/send", + path: "/send/lnurl-pay", + params: { + lnurlDetailsJSON: JSON.stringify(mockLNURLPayResponse), + receiver: "hello@getalby.com", + }, }, "LIGHTNING:hello@getalby.com": { - url: "hello@getalby.com", - path: "/send", + path: "/send/lnurl-pay", + params: { + lnurlDetailsJSON: JSON.stringify(mockLNURLPayResponse), + receiver: "hello@getalby.com", + }, }, // Lightning invoices "lightning:lnbc123": { - url: "lnbc123", path: "/send", + params: { url: "lnbc123" }, }, "lightning://lnbc123": { - url: "lnbc123", path: "/send", + params: { url: "lnbc123" }, }, // BIP21 "bitcoin:bitcoinaddress?lightning=lnbc123": { - url: "lnbc123", path: "/send", + params: { url: "lnbc123" }, }, "BITCOIN:bitcoinaddress?lightning=lnbc123": { - url: "lnbc123", path: "/send", + params: { url: "lnbc123" }, }, // LNURL-withdraw "lightning:lnurlw123": { - url: "lnurlw123", path: "/withdraw", + params: { url: "lnurlw123" }, }, "lightning://lnurlw123": { - url: "lnurlw123", path: "/withdraw", + params: { url: "lnurlw123" }, }, }; @@ -104,7 +134,7 @@ describe("handleLink", () => { "should parse the URL '%s' and navigate correctly", async (url, expectedOutput) => { await handleLink("exp://127.0.0.1:8081/--/" + url); - assertRedirect(expectedOutput.path, expectedOutput.url); + assertRedirect(expectedOutput.path, expectedOutput.params); }, ); }); @@ -114,17 +144,15 @@ describe("handleLink", () => { "should parse the URL '%s' and navigate correctly", async (url, expectedOutput) => { await handleLink(url); - assertRedirect(expectedOutput.path, expectedOutput.url); + assertRedirect(expectedOutput.path, expectedOutput.params); }, ); }); }); -const assertRedirect = (expectedPath: string, expectedUrl: string) => { +const assertRedirect = (expectedPath: string, expectedParams: any) => { expect(router.push).toHaveBeenCalledWith({ pathname: expectedPath, - params: { - url: expectedUrl, - }, + params: expectedParams, }); }; diff --git a/lib/link.ts b/lib/link.ts index 0584e095..787d821e 100644 --- a/lib/link.ts +++ b/lib/link.ts @@ -159,9 +159,10 @@ export const handleLink = async (url: string) => { if (lnurlDetails.tag === "payRequest") { router.push({ - pathname: "/send", + pathname: "/send/lnurl-pay", params: { - url: lnurl, + lnurlDetailsJSON: JSON.stringify(lnurlDetails), + receiver: lnurl, }, }); return; diff --git a/lib/processPayment.ts b/lib/processPayment.ts new file mode 100644 index 00000000..4128960b --- /dev/null +++ b/lib/processPayment.ts @@ -0,0 +1,97 @@ +import { Invoice } from "@getalby/lightning-tools"; +import { router } from "expo-router"; +import { lnurl as lnurlLib } from "lib/lnurl"; +import { errorToast } from "~/lib/errorToast"; +import { convertMerchantQRToLightningAddress } from "~/lib/merchants"; + +export async function processPayment( + text: string, + amount: string, +): Promise { + // Some apps use uppercased LIGHTNING: prefixes + text = text.toLowerCase(); + console.info("loading payment", text); + const originalText = text; + + try { + if (text.startsWith("bitcoin:")) { + const universalUrl = text.replace("bitcoin:", "http://"); + const url = new URL(universalUrl); + const lightningParam = url.searchParams.get("lightning"); + if (!lightningParam) { + throw new Error("No lightning param found in bitcoin payment link"); + } + text = lightningParam; + } + + if (text.startsWith("lightning:")) { + text = text.substring("lightning:".length); + } + + // convert picknpay QRs to lighnting addresses + const merchantLightningAddress = convertMerchantQRToLightningAddress(text); + if (merchantLightningAddress) { + text = merchantLightningAddress; + } + + const lnurl = lnurlLib.findLnurl(text); + console.info("Checked lnurl value", text, lnurl); + + if (lnurl) { + const lnurlDetails = await lnurlLib.getDetails(lnurl); + + if ( + lnurlDetails.tag !== "payRequest" && + lnurlDetails.tag !== "withdrawRequest" + ) { + throw new Error("LNURL tag not supported"); + } + + if (lnurlDetails.tag === "withdrawRequest") { + router.replace({ + pathname: "/withdraw", + params: { url: lnurl }, + }); + return true; + } + + if (lnurlDetails.tag === "payRequest") { + router.replace({ + pathname: "/send/lnurl-pay", + params: { + lnurlDetailsJSON: JSON.stringify(lnurlDetails), + receiver: lnurl, + amount, + }, + }); + return true; + } + } else { + // Check if this is a valid invoice + const invoice = new Invoice({ pr: text }); + + if (invoice.satoshi === 0) { + router.replace({ + pathname: "/send/0-amount", + params: { + invoice: text, + receiver: originalText, + comment: invoice.description, + }, + }); + return true; + } + + router.replace({ + pathname: "/send/confirm", + params: { invoice: text, receiver: originalText }, + }); + return true; + } + } catch (error) { + console.error("failed to load payment", originalText, error); + errorToast(error); + } + + return false; +} diff --git a/pages/send/ConfirmPayment.tsx b/pages/send/ConfirmPayment.tsx index 8ac331f1..37532606 100644 --- a/pages/send/ConfirmPayment.tsx +++ b/pages/send/ConfirmPayment.tsx @@ -19,10 +19,10 @@ import { useAppStore } from "~/lib/state/appStore"; export function ConfirmPayment() { const { data: transactions } = useTransactions(); - const { invoice, originalText, comment, successAction, amount } = + const { invoice, receiver, comment, successAction, amount } = useLocalSearchParams() as { invoice: string; - originalText: string; + receiver: string; comment: string; successAction: string; amount?: string; @@ -50,7 +50,7 @@ export function ConfirmPayment() { console.info("payInvoice Response", response); - if (originalText === ALBY_LIGHTNING_ADDRESS) { + if (receiver === ALBY_LIGHTNING_ADDRESS) { useAppStore.getState().updateLastAlbyPayment(); } @@ -59,7 +59,7 @@ export function ConfirmPayment() { pathname: "/send/success", params: { preimage: response.preimage, - originalText, + receiver, invoice, amount: amountToPaySats, successAction, @@ -112,7 +112,7 @@ export function ConfirmPayment() { ) )} - + diff --git a/pages/send/LNURLPay.tsx b/pages/send/LNURLPay.tsx index 50bb0b90..ebf7d219 100644 --- a/pages/send/LNURLPay.tsx +++ b/pages/send/LNURLPay.tsx @@ -1,6 +1,7 @@ import { router, useLocalSearchParams } from "expo-router"; import React, { useEffect } from "react"; import { View } from "react-native"; +import Toast from "react-native-toast-message"; import DismissableKeyboardView from "~/components/DismissableKeyboardView"; import { DualCurrencyInput } from "~/components/DualCurrencyInput"; import Loading from "~/components/Loading"; @@ -16,11 +17,11 @@ import { cn } from "~/lib/utils"; export function LNURLPay() { const { lnurlDetailsJSON, - originalText, + receiver, amount: amountParam, } = useLocalSearchParams() as unknown as { lnurlDetailsJSON: string; - originalText: string; + receiver: string; amount: string; }; const lnurlDetails: LNURLPayServiceResponse = JSON.parse(lnurlDetailsJSON); @@ -41,6 +42,11 @@ export function LNURLPay() { if (lnurlDetails.minSendable === lnurlDetails.maxSendable) { setAmount((lnurlDetails.minSendable / 1000).toString()); setAmountReadOnly(true); + Toast.show({ + type: "success", + text1: "You are paying a fixed amount invoice", + position: "top", + }); } }, [lnurlDetails.minSendable, lnurlDetails.maxSendable]); @@ -59,7 +65,7 @@ export function LNURLPay() { pathname: "/send/confirm", params: { invoice: lnurlPayInfo.pr, - originalText, + receiver, comment, successAction: lnurlPayInfo.successAction ? JSON.stringify(lnurlPayInfo.successAction) @@ -105,19 +111,21 @@ export function LNURLPay() { sats - - - Comment - - - - + {!!lnurlDetails.commentAllowed && ( + + + Comment + + + + )} + + + + + ); +} diff --git a/pages/send/PaymentSuccess.tsx b/pages/send/PaymentSuccess.tsx index 3d3c5597..466f33c8 100644 --- a/pages/send/PaymentSuccess.tsx +++ b/pages/send/PaymentSuccess.tsx @@ -13,10 +13,10 @@ import { useGetFiatAmount } from "~/hooks/useGetFiatAmount"; export function PaymentSuccess() { const getFiatAmount = useGetFiatAmount(); - const { originalText, invoice, amount, successAction } = + const { receiver, invoice, amount, successAction } = useLocalSearchParams() as { preimage: string; - originalText: string; + receiver: string; invoice: string; amount: string; successAction: string; @@ -49,7 +49,7 @@ export function PaymentSuccess() { )} - + {lnurlSuccessAction && ( diff --git a/pages/send/Send.tsx b/pages/send/Send.tsx index 02b3b06f..297835d9 100644 --- a/pages/send/Send.tsx +++ b/pages/send/Send.tsx @@ -1,29 +1,24 @@ -import { Invoice } from "@getalby/lightning-tools"; import * as Clipboard from "expo-clipboard"; import { router, useLocalSearchParams } from "expo-router"; -import { lnurl } from "lib/lnurl"; import React from "react"; import { View } from "react-native"; -import DismissableKeyboardView from "~/components/DismissableKeyboardView"; -import { BookUserIcon, EditIcon, PasteIcon } from "~/components/Icons"; +import { BookUserIcon, KeyboardIcon, PasteIcon } from "~/components/Icons"; import Loading from "~/components/Loading"; import QRCodeScanner from "~/components/QRCodeScanner"; import Screen from "~/components/Screen"; import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; import { Text } from "~/components/ui/text"; import { errorToast } from "~/lib/errorToast"; import { handleLink } from "~/lib/link"; -import { convertMerchantQRToLightningAddress } from "~/lib/merchants"; +import { processPayment } from "~/lib/processPayment"; export function Send() { const { url, amount } = useLocalSearchParams<{ url: string; amount: string; }>(); + const [isLoading, setLoading] = React.useState(false); - const [keyboardOpen, setKeyboardOpen] = React.useState(false); - const [keyboardText, setKeyboardText] = React.useState(""); const [startScanning, setStartScanning] = React.useState(false); async function paste() { @@ -34,148 +29,33 @@ export function Send() { console.error("Failed to read clipboard", error); return; } - loadPayment(clipboardText); - } - - function openKeyboard() { - setKeyboardOpen(true); + await loadPayment(clipboardText); } - const handleScanned = (data: string) => { + const handleScanned = async (data: string) => { return loadPayment(data); }; - function submitKeyboardText() { - loadPayment(keyboardText); - } - - const loadPayment = React.useCallback( - async (text: string): Promise => { - if (!text) { - errorToast(new Error("Your clipboard is empty.")); - return false; - } - - if (text.startsWith("nostr+walletauth") /* can have : or +alby: */) { - handleLink(text); - return true; - } - - // Some apps use uppercased LIGHTNING: prefixes - text = text.toLowerCase(); - - console.info("loading payment", text); - const originalText = text; - setLoading(true); - try { - if (text.startsWith("bitcoin:")) { - const universalUrl = text.replace("bitcoin:", "http://"); - const url = new URL(universalUrl); - const lightningParam = url.searchParams.get("lightning"); - if (!lightningParam) { - throw new Error("No lightning param found in bitcoin payment link"); - } - text = lightningParam; - } - if (text.startsWith("lightning:")) { - text = text.substring("lightning:".length); - } - - // convert picknpay QRs to lightning addresses - const merchantLightningAddress = - convertMerchantQRToLightningAddress(text); - if (merchantLightningAddress) { - text = merchantLightningAddress; - } - - const lnurlValue = lnurl.findLnurl(text); - console.info("Checked lnurl value", text, lnurlValue); - if (lnurlValue) { - const lnurlDetails = await lnurl.getDetails(lnurlValue); - - if ( - lnurlDetails.tag !== "payRequest" && - lnurlDetails.tag !== "withdrawRequest" - ) { - throw new Error("LNURL tag not supported"); - } - - if (lnurlDetails.tag === "withdrawRequest") { - router.replace({ - pathname: "/withdraw", - params: { - url: lnurlValue, - }, - }); - return true; - } - - // Handle fixed amount LNURLs - if ( - lnurlDetails.minSendable === lnurlDetails.maxSendable && - !lnurlDetails.commentAllowed - ) { - const callback = new URL(lnurlDetails.callback); - callback.searchParams.append( - "amount", - lnurlDetails.minSendable.toString(), - ); - const lnurlPayInfo = await lnurl.getPayRequest(callback.toString()); - router.replace({ - pathname: "/send/confirm", - params: { invoice: lnurlPayInfo.pr, originalText }, - }); - } else { - router.replace({ - pathname: "/send/lnurl-pay", - params: { - lnurlDetailsJSON: JSON.stringify(lnurlDetails), - originalText, - amount, - }, - }); - } - - return true; - } else { - // Check if this is a valid invoice - const invoice = new Invoice({ pr: text }); - - if (invoice.satoshi === 0) { - router.replace({ - pathname: "/send/0-amount", - params: { - invoice: text, - originalText, - comment: invoice.description, - }, - }); - return true; - } - - router.replace({ - pathname: "/send/confirm", - params: { invoice: text, originalText }, - }); - return true; - } - } catch (error) { - console.error("failed to load payment", originalText, error); - errorToast(error); - } finally { - setLoading(false); - } - + const loadPayment = async (text: string, amount = "") => { + if (!text) { + errorToast(new Error("Your clipboard is empty.")); return false; - }, - [amount], - ); + } + + if (text.startsWith("nostr+walletauth") /* can have : or +alby: */) { + handleLink(text); + return true; + } + setLoading(true); + const result = await processPayment(text, amount); + setLoading(false); + return result; + }; - // Delay starting the QR scanner if url has valid payment info React.useEffect(() => { if (url) { (async () => { - const result = await loadPayment(url); + const result = await loadPayment(url, amount); // Delay the camera to show the error message if (!result) { setTimeout(() => { @@ -186,7 +66,7 @@ export function Send() { } else { setStartScanning(true); } - }, [loadPayment, url]); + }, [url, amount]); return ( <> @@ -198,67 +78,40 @@ export function Send() { )} {!isLoading && ( <> - {!keyboardOpen && ( - <> - - - - - - - - )} - {keyboardOpen && ( - - - - - Type or paste a Lightning Address, lightning invoice or - LNURL. - - - - - - - )} + + + + + + )} diff --git a/pages/send/ZeroAmount.tsx b/pages/send/ZeroAmount.tsx index e7a03e71..70a82cac 100644 --- a/pages/send/ZeroAmount.tsx +++ b/pages/send/ZeroAmount.tsx @@ -11,12 +11,11 @@ import { Text } from "~/components/ui/text"; import { errorToast } from "~/lib/errorToast"; export function ZeroAmount() { - const { invoice, originalText, comment } = - useLocalSearchParams() as unknown as { - invoice: string; - originalText: string; - comment: string; - }; + const { invoice, receiver, comment } = useLocalSearchParams() as unknown as { + invoice: string; + receiver: string; + comment: string; + }; const [isLoading, setLoading] = React.useState(false); const [amount, setAmount] = React.useState(""); @@ -27,7 +26,7 @@ export function ZeroAmount() { pathname: "/send/confirm", params: { invoice, - originalText, + receiver, amount, }, }); @@ -63,7 +62,7 @@ export function ZeroAmount() { )} - +