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
+
+
+
+ )}
+
-
+
{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 && (
- <>
-
-
-
-
- Amount
-
- {
- router.push("/send/address-book");
- }}
- >
-
- Contacts
-
-
-
- Paste
-
-
- >
- )}
- {keyboardOpen && (
-
-
-
-
- Type or paste a Lightning Address, lightning invoice or
- LNURL.
-
-
-
-
- Next
-
-
-
- )}
+
+
+ {
+ router.push("/send/manual");
+ }}
+ variant="secondary"
+ className="flex flex-col gap-2 flex-1"
+ >
+
+ Manual
+
+ {
+ router.push("/send/address-book");
+ }}
+ >
+
+ Contacts
+
+
+
+ Paste
+
+
>
)}
>
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() {
)}
-
+