Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add lnurl withdrawal #158

Merged
merged 8 commits into from
Oct 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/(app)/withdraw/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Withdraw } from "../../../pages/withdraw/Withdraw";

export default function Page() {
return <Withdraw />;
}
2 changes: 1 addition & 1 deletion components/DismissableKeyboardView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Platform, KeyboardAvoidingView, Keyboard, TouchableWithoutFeedback } from "react-native";

function DismissableKeyboardView({ children }: { children?: React.ReactNode | undefined }) {
function DismissableKeyboardView({ children }: { children?: React.ReactNode }) {
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
Expand Down
5 changes: 4 additions & 1 deletion components/Icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
CircleCheck,
TriangleAlert,
LogOut,
ArchiveRestore
} from "lucide-react-native";
import { cssInterop } from "nativewind";

Expand Down Expand Up @@ -93,6 +94,7 @@ interopIcon(HelpCircle);
interopIcon(CircleCheck);
interopIcon(TriangleAlert);
interopIcon(LogOut);
interopIcon(ArchiveRestore);

export {
AlertCircle,
Expand Down Expand Up @@ -133,5 +135,6 @@ export {
HelpCircle,
CircleCheck,
TriangleAlert,
LogOut
LogOut,
ArchiveRestore
};
113 changes: 84 additions & 29 deletions hooks/__tests__/useHandleLinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,58 +3,113 @@ import { router } from "expo-router";

jest.mock("expo-router");

const testVectors: Record<string, string> = {
"lightning:hello@getalby.com": "lightning:hello@getalby.com",
"lightning://hello@getalby.com": "lightning:hello@getalby.com",
"LIGHTNING://hello@getalby.com": "lightning:hello@getalby.com",
"LIGHTNING:hello@getalby.com": "lightning:hello@getalby.com",
"lightning:lnbc1": "lightning:lnbc1",
"lightning://lnbc1": "lightning:lnbc1",
"bitcoin:bitcoinaddress?lightning=invoice":
"bitcoin:bitcoinaddress?lightning=invoice",
"BITCOIN:bitcoinaddress?lightning=invoice":
"bitcoin:bitcoinaddress?lightning=invoice",
// Mock the lnurl module
jest.mock("../../lib/lnurl", () => {
const originalModule = jest.requireActual("../../lib/lnurl");

const mockGetDetails = jest.fn(async (lnurlString) => {
if (lnurlString.startsWith("lnurlw")) {
return {
tag: "withdrawRequest",
callback: "https://getalby.com/callback",
k1: "unused",
defaultDescription: "withdrawal",
minWithdrawable: 21000,
maxWithdrawable: 21000,
};
}
return originalModule.lnurl.getDetails(lnurlString);
});

return {
...originalModule,
lnurl: {
...originalModule.lnurl,
getDetails: mockGetDetails,
},
};
});

const testVectors: Record<string, { url: string; path: string }> = {
// Lightning Addresses
"lightning:hello@getalby.com": {
url: "lightning:hello@getalby.com",
path: "/send",
},
"lightning://hello@getalby.com": {
url: "lightning:hello@getalby.com",
path: "/send",
},
"LIGHTNING://hello@getalby.com": {
url: "lightning:hello@getalby.com",
path: "/send",
},
"LIGHTNING:hello@getalby.com": {
url: "lightning:hello@getalby.com",
path: "/send",
},

// Lightning invoices
"lightning:lnbc1": { url: "lightning:lnbc1", path: "/send" },
"lightning://lnbc1": { url: "lightning:lnbc1", path: "/send" },

// BIP21
"bitcoin:bitcoinaddress?lightning=invoice": {
url: "bitcoin:bitcoinaddress?lightning=invoice",
path: "/send",
},
"BITCOIN:bitcoinaddress?lightning=invoice": {
url: "bitcoin:bitcoinaddress?lightning=invoice",
path: "/send",
},

// LNURL-withdraw
"lightning:lnurlw123": { url: "lightning:lnurlw123", path: "/withdraw" },
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah nice, thanks @reneaaron 🙌

};

describe("handleLink", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("should return early if url is empty", () => {
handleLink("");
it("should return early if url is empty", async () => {
await handleLink("");
expect(router.push).not.toHaveBeenCalled();
expect(router.replace).not.toHaveBeenCalled();
});

it("should return early if scheme is not supported", () => {
handleLink("mailto:hello@getalby.com");
it("should return early if scheme is not supported", async () => {
await handleLink("mailto:hello@getalby.com");
expect(router.replace).toHaveBeenCalledWith({
pathname: "/",
});
expect(router.push).not.toHaveBeenCalled();
});

it("should parse the URL and navigate correctly for expo links", () => {
Object.entries(testVectors).forEach(([url, expectedOutput]) => {
jest.clearAllMocks();
handleLink("exp://127.0.0.1:8081/--/" + url);
assertRedirect(expectedOutput);
});
describe("Expo links", () => {
test.each(Object.entries(testVectors))(
"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);
},
);
});

it("should parse the URL and navigate correctly for production links", () => {
Object.entries(testVectors).forEach(([url, expectedOutput]) => {
jest.clearAllMocks();
handleLink(url);
assertRedirect(expectedOutput);
});
describe("Production links", () => {
test.each(Object.entries(testVectors))(
"should parse the URL '%s' and navigate correctly",
async (url, expectedOutput) => {
await handleLink(url);
assertRedirect(expectedOutput.path, expectedOutput.url);
},
);
});
});

const assertRedirect = (expectedUrl: string) => {
const assertRedirect = (expectedPath: string, expectedUrl: string) => {
expect(router.push).toHaveBeenCalledWith({
pathname: "/send",
pathname: expectedPath,
params: {
url: expectedUrl,
},
Expand Down
6 changes: 3 additions & 3 deletions hooks/useHandleLinking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,15 @@ export function useHandleLinking() {

const processInitialURL = async () => {
const url = await getInitialURL();
if (url) handleLink(url);
if (url) await handleLink(url);
};

processInitialURL();

const subscription = Linking.addEventListener(
"url",
(event: { url: string }) => {
handleLink(event.url);
async (event: { url: string }) => {
await handleLink(event.url);
},
);

Expand Down
18 changes: 17 additions & 1 deletion lib/link.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { router } from "expo-router";
import { lnurl } from "./lnurl";

const SUPPORTED_SCHEMES = ["lightning:", "bitcoin:", "alby:"];

Expand All @@ -8,7 +9,7 @@ if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test") {
SUPPORTED_SCHEMES.push("exp:");
}

export const handleLink = (url: string) => {
export const handleLink = async (url: string) => {
if (!url) return;

const parsedUrl = new URL(url);
Expand Down Expand Up @@ -37,6 +38,21 @@ export const handleLink = (url: string) => {

console.log("Navigating to", fullUrl);

const lnurlValue = lnurl.findLnurl(fullUrl);
if (lnurlValue) {
const lnurlDetails = await lnurl.getDetails(lnurlValue)

if (lnurlDetails.tag === "withdrawRequest") {
router.push({
pathname: "/withdraw",
params: {
url: fullUrl,
},
});
return;
}
}

router.push({
pathname: "/send",
params: {
Expand Down
18 changes: 16 additions & 2 deletions lib/lnurl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,23 @@ export interface LNURLPayServiceResponse {
url: string;
}

type LNURLDetails = LNURLPayServiceResponse;
export interface LNURLWithdrawServiceResponse {
tag: "withdrawRequest"; // type of LNURL
callback: string; // The URL which LN SERVICE would accept a withdrawal Lightning invoice as query parameter
k1: string; // Random or non-random string to identify the user's LN WALLET when using the callback URL
defaultDescription: string; // A default withdrawal invoice description
balanceCheck?: string;
payLink?: string;
minWithdrawable: number; // Min amount (in millisatoshis) the user can withdraw from LN SERVICE, or 0
maxWithdrawable: number; // Max amount (in millisatoshis) the user can withdraw from LN SERVICE, or equal to minWithdrawable if the user has no choice over the amounts
domain: string;
url: string;
}

type LNURLDetails =
| LNURLPayServiceResponse
| LNURLWithdrawServiceResponse;
//| LNURLAuthServiceResponse
//| LNURLWithdrawServiceResponse;

export interface LNURLPaymentSuccessAction {
tag: string;
Expand Down
43 changes: 29 additions & 14 deletions pages/receive/Receive.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Link, router } from "expo-router";
import { Share, View } from "react-native";
import { Share, TouchableOpacity, View } from "react-native";
import { Button } from "~/components/ui/button";
import * as Clipboard from "expo-clipboard";
import React from "react";
import { useAppStore } from "~/lib/state/appStore";
import { Input } from "~/components/ui/input";
import { Text } from "~/components/ui/text";
import { Copy, Share2, ZapIcon } from "~/components/Icons";
import { ArchiveRestore, Copy, Share2, ZapIcon } from "~/components/Icons";
import Toast from "react-native-toast-message";
import { errorToast } from "~/lib/errorToast";
import { Nip47Transaction } from "@getalby/sdk/dist/NWCClient";
Expand All @@ -24,7 +24,6 @@ export function Receive() {
const invoiceRef = React.useRef("");
const [amount, setAmount] = React.useState("");
const [comment, setComment] = React.useState("");
const [addComment, setAddComment] = React.useState(false);
const [enterCustomAmount, setEnterCustomAmount] = React.useState(false);
const selectedWalletId = useAppStore((store) => store.selectedWalletId);
const wallets = useAppStore((store) => store.wallets);
Expand Down Expand Up @@ -224,9 +223,11 @@ export function Receive() {
</View>
) : (
lightningAddress && (
<Text className="text-foreground text-xl font-medium2">
{lightningAddress}
</Text>
<TouchableOpacity onPress={copy}>
<Text className="text-foreground text-xl font-medium2">
{lightningAddress}
</Text>
</TouchableOpacity>
)
)}
{invoice && getFiatAmount && (
Expand All @@ -252,14 +253,16 @@ export function Receive() {
<Share2 className="text-muted-foreground" />
<Text>Share</Text>
</Button>
<Button
variant="secondary"
onPress={copy}
className="flex-1 flex flex-col gap-2"
>
<Copy className="text-muted-foreground" />
<Text>Copy</Text>
</Button>
{!enterCustomAmount && invoice &&
<Button
variant="secondary"
onPress={copy}
className="flex-1 flex flex-col gap-2"
>
<Copy className="text-muted-foreground" />
<Text>Copy</Text>
</Button>
}
{!enterCustomAmount && !invoice && (
<Button
variant="secondary"
Expand All @@ -270,6 +273,18 @@ export function Receive() {
<Text>Invoice</Text>
</Button>
)}
{!enterCustomAmount && !invoice &&
<Button
variant="secondary"
className="flex-1 flex flex-col gap-2"
onPress={() => {
router.push("/withdraw");
}}
>
<ArchiveRestore className="text-muted-foreground" />
<Text>Withdraw</Text>
</Button>
}
</View>
</>
)}
Expand Down
Loading
Loading