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: NWA + NWC deep linking #272

Open
wants to merge 20 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
feat: nwc deep linking
  • Loading branch information
im-adithya committed Feb 19, 2025
commit 18ca376edc58a5775d3ab692e66b6787c3722071
9 changes: 8 additions & 1 deletion app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ export default ({ config }) => {
name: "Alby Go",
slug: "alby-mobile",
version: "1.9.0",
scheme: ["lightning", "bitcoin", "alby", "nostr+walletconnect"],
scheme: [
"lightning",
"bitcoin",
"alby",
"nostr+walletconnect",
"nostrnwc",
"nostrnwc+alby",
],
orientation: "portrait",
icon: "./assets/icon.png",
userInterfaceStyle: "automatic",
Expand Down
5 changes: 5 additions & 0 deletions app/(app)/settings/wallets/connect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ConnectWallet } from "../../../../pages/settings/wallets/ConnectWallet";

export default function Page() {
return <ConnectWallet />;
}
Binary file added assets/hub.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions components/Icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
PopiconsAddressBookSolid as BookUserIcon,
PopiconsCameraWebOffSolid as CameraOffIcon,
PopiconsCircleCheckLine as CheckCircleIcon,
PopiconsChevronRightSolid as ChevronRightIcon,
PopiconsChevronTopLine as ChevronUpIcon,
PopiconsPlugSolid as ConnectIcon,
PopiconsCopySolid as CopyIcon,
PopiconsEditSolid as EditIcon,
PopiconsUploadSolid as ExportIcon,
Expand Down Expand Up @@ -56,6 +58,8 @@ interopIcon(BookUserIcon);
interopIcon(CameraOffIcon);
interopIcon(CheckCircleIcon);
interopIcon(ChevronUpIcon);
interopIcon(ChevronRightIcon);
interopIcon(ConnectIcon);
interopIcon(CopyIcon);
interopIcon(EditIcon);
interopIcon(ExportIcon);
Expand Down Expand Up @@ -91,7 +95,9 @@ export {
BookUserIcon,
CameraOffIcon,
CheckCircleIcon,
ChevronRightIcon,
ChevronUpIcon,
ConnectIcon,
CopyIcon,
EditIcon,
ExportIcon,
Expand Down
30 changes: 30 additions & 0 deletions lib/link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const SUPPORTED_SCHEMES = [
"bitcoin:",
"alby:",
"nostr+walletconnect:",
"nostrnwc:",
"nostrnwc+alby:",
];

// Register exp scheme for testing during development
Expand All @@ -27,6 +29,34 @@ export const handleLink = async (url: string) => {

if (SUPPORTED_SCHEMES.indexOf(parsedUrl.protocol) > -1) {
let { username, hostname, protocol, pathname, search } = parsedUrl;
if (parsedUrl.protocol.startsWith("nostrnwc")) {
if (router.canDismiss()) {
router.dismissAll();
}

const params = new URLSearchParams(search);
const appname = params.get("appname");
const rawCallback = params.get("callback");
const rawAppIcon = params.get("appicon");
if (!appname || !rawCallback || !rawAppIcon) {
return;
}

const appicon = decodeURIComponent(rawAppIcon);
const callback = decodeURIComponent(rawCallback);

console.info("Navigating to NWA flow");
router.push({
pathname: "/settings/wallets/connect",
params: {
appicon,
appname,
callback,
},
});
return;
}

if (parsedUrl.protocol === "nostr+walletconnect:") {
if (router.canDismiss()) {
router.dismissAll();
Expand Down
233 changes: 233 additions & 0 deletions pages/settings/wallets/ConnectWallet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import { bytesToHex } from "@noble/hashes/utils";
import { Link, router, useLocalSearchParams } from "expo-router";
import { generateSecretKey, getPublicKey } from "nostr-tools";
import React from "react";
import { Image, TouchableOpacity, View } from "react-native";
import { ChevronRightIcon, ConnectIcon, XIcon } from "~/components/Icons";
import Screen from "~/components/Screen";
import { Button } from "~/components/ui/button";
import { Text } from "~/components/ui/text";

import { openURL } from "expo-linking";
import { Tick } from "~/animations/Tick";
import { WalletIcon } from "~/components/Icons";
import Loading from "~/components/Loading";
import { DEFAULT_WALLET_NAME } from "~/lib/constants";
import { errorToast } from "~/lib/errorToast";
import { useAppStore } from "~/lib/state/appStore";

export function ConnectWallet() {
const { appicon, appname, callback } = useLocalSearchParams<{
appicon: string;
appname: string;
callback: string;
}>();
const [isLoading, setLoading] = React.useState(false);
const wallets = useAppStore((store) => store.wallets);
const selectedWalletId = useAppStore((store) => store.selectedWalletId);

const [nwcUrl, setNwcUrl] = React.useState<string | null>(null);
const [redirectCountdown, setRedirectCountdown] = React.useState<
number | null
>(null);

React.useEffect(() => {
if (redirectCountdown === null || !nwcUrl) {
return;
}

if (redirectCountdown === 0) {
(async () => {
const callbackUrl = `${callback}?value=${nwcUrl}`;
try {
await openURL(callbackUrl);
} catch (error) {
console.error(error);
errorToast(
new Error("Couldn't open URL, do you have the app installed?"),
);
}
if (router.canDismiss()) {
router.dismissAll();
}
router.replace("/");
})();
return;
}

const timer = setTimeout(() => {
setRedirectCountdown((prev) => (prev ? prev - 1 : prev));
}, 1000);
return () => clearTimeout(timer);
}, [redirectCountdown, nwcUrl, callback]);

const confirm = async () => {
setLoading(true);
try {
const nwcClient = useAppStore.getState().nwcClient;
if (!nwcClient) {
throw new Error("NWC client not connected");
}
Copy link
Member Author

Choose a reason for hiding this comment

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

We will already have the nip47capabilities of the wallet (and create_connection can't also be edited so we don't even have that problem here (since we don't update the nip47capabilities and stick to the ones we got on fetching the first time)) so should we check for that here or even before so the user is not betrayed xD after all these steps?

Copy link
Member Author

Choose a reason for hiding this comment

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

We can do it as a follow-up too but I think it's good if we can show a screen for those scanning NWAs with their old Alby Go connections with a message to reconnect with their Hubs cc @stackingsaunter

Copy link
Contributor

Choose a reason for hiding this comment

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

@im-adithya

We will already have the nip47capabilities of the wallet (and create_connection can't also be edited so we don't even have that problem here (since we don't update the nip47capabilities and stick to the ones we got on fetching the first time)) so should we check for that here or even before so the user is not betrayed xD after all these steps?

Sorry, I don't understand your comment here. I understand we don't need to do get_info.

But we also have this page so the user should immediately see it if their connection doesn't support it

image

Copy link
Member Author

Choose a reason for hiding this comment

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

Yup sorry I meant this

I don't understand your comment here

(unrelated to this PR) I was wondering if we should keep updating that wallet.nwcCapabilities on a regular basis since the user might have updated the permissions. But here we don't have to think about that since superuser permission can't be added later.

Other parts in the comment you can ignore, I didn't see this unsupported screen then 😅

let secretKey = generateSecretKey();
let pubkey = getPublicKey(secretKey);
{
/* TODO: REPLACE WITH BUDGET INFO (AND METHODS?) */
}
const response = await nwcClient.createConnection({

Check failure on line 76 in pages/settings/wallets/ConnectWallet.tsx

View workflow job for this annotation

GitHub Actions / linting

Property 'createConnection' does not exist on type 'NWCClient'.
pubkey,
name: appname,
budget: {
budget: 10_000_000,
renewal_period: "monthly",
},
methods: [
"get_info",
"get_balance",
"get_budget",
"make_invoice",
"pay_invoice",
"lookup_invoice",
"list_transactions",
"sign_message",
],
metadata: null,
});

console.info("createConnection response", response);

const newUrl = `nostr+walletconnect://${response.wallet_pubkey}?secret=${bytesToHex(
secretKey,
)}&relay=${nwcClient.relayUrl}`;

setNwcUrl(newUrl);
setRedirectCountdown(5);
Copy link
Contributor

Choose a reason for hiding this comment

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

Here we artificially delay the flow by 5 seconds, I wonder if it's too much. What is the reasoning here?

Copy link
Member Author

Choose a reason for hiding this comment

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

That was actually to finish the Tick animation lol, but this can be lesser, or do you think we should not care about the animation and switch instantly?

} catch (error) {
console.error(error);
errorToast(error);
}
setLoading(false);
};

return (
<>
<Screen
title={nwcUrl ? "Wallet Connected" : "Connect Wallet"}
right={() => (
<TouchableOpacity
onPressIn={() => {
if (router.canDismiss()) {
router.dismissAll();
}
router.replace("/");
}}
className="-mr-4 px-6"
>
<XIcon className="text-muted-foreground" width={24} height={24} />
</TouchableOpacity>
)}
/>
{/* TODO: CHECK IF PEOPLE CAN EXECUTE SCRIPTS FROM THE IMAGE URL */}
<View className="flex-1 justify-center items-center gap-8 p-6">
<View className="flex flex-row items-center justify-center gap-8">
<View className="flex items-center">
<View className="shadow">
<Image
source={require("../../../assets/hub.png")}
className="my-4 rounded-2xl w-20 h-20"
/>
</View>
<Text className="text-xl font-semibold2">Alby Hub</Text>
</View>
<ConnectIcon
className="text-muted-foreground rotate-45 mb-4"
width={30}
height={30}
/>
<View className="flex items-center">
<View className="shadow">
<Image
source={{ uri: appicon }}
className="my-4 rounded-2xl shadow-md w-20 h-20"
/>
</View>
<Text className="text-xl font-semibold2">{appname}</Text>
</View>
</View>
{nwcUrl ? (
<View className="flex-1 w-full justify-center items-center">
<Tick />
<Text className="my-4 text-lg text-center text-foreground">
Connected! You're being redirected in {redirectCountdown}{" "}
seconds...
</Text>
</View>
) : (
<>
<Text className="text-xl text-center text-foreground">
<Text className="text-xl font-semibold2">{appname}</Text> is
requesting to access your wallet{" "}
<Text className="text-xl font-semibold2">Alby Hub</Text> for
in-app payments.
</Text>
<View className="flex-1 flex mt-4 gap-8 justify-center">
<View>
{/* TODO: REPLACE WITH A PROPER COMPONENT */}
<TouchableOpacity className="flex flex-row border-2 border-muted justify-between items-center rounded-2xl pl-6 pr-16 py-4">
<Text className="text-xl font-medium2">Monthly budget</Text>
<View>
<Text className="text-right text-lg text-foreground font-medium2">
100 000 sats
</Text>
<Text className="text-right text-sm text-muted-foreground">
~$10.52
</Text>
</View>
<Link
href={`/settings/wallets`}
className="absolute right-0"
asChild
>
<TouchableOpacity className="p-4">
<ChevronRightIcon
className="text-muted-foreground"
width={24}
height={24}
/>
</TouchableOpacity>
</Link>
</TouchableOpacity>
<Text className="mt-4 text-center text-foreground">
You can edit permissions and revoke access at any time in your
Alby Hub settings.
</Text>
</View>
</View>
</>
)}
</View>
{!nwcUrl && (
<View className="p-6">
<View className="flex flex-row items-center justify-center gap-2 mb-4 px-4">
<WalletIcon className="text-muted-foreground" />
<Text
numberOfLines={1}
ellipsizeMode="tail"
className="text-muted-foreground font-medium2 text-xl"
>
{wallets[selectedWalletId].name || DEFAULT_WALLET_NAME}
</Text>
</View>
<Button
size="lg"
onPress={confirm}
className="flex flex-row gap-2"
disabled={isLoading}
>
{isLoading && <Loading className="text-primary-foreground" />}
<Text>Confirm Connection</Text>
</Button>
</View>
)}
</>
);
}
Loading