diff --git a/packages/app/src/Icons/Nostrich.tsx b/packages/app/src/Icons/Nostrich.tsx new file mode 100644 index 00000000..6dc33122 --- /dev/null +++ b/packages/app/src/Icons/Nostrich.tsx @@ -0,0 +1,13 @@ +import IconProps from "./IconProps"; + +export default function NostrIcon(props: IconProps) { + return ( + + + + ); +} diff --git a/packages/app/src/Pages/settings/WalletSettings.tsx b/packages/app/src/Pages/settings/WalletSettings.tsx index 3c391499..fc02e21e 100644 --- a/packages/app/src/Pages/settings/WalletSettings.tsx +++ b/packages/app/src/Pages/settings/WalletSettings.tsx @@ -5,7 +5,9 @@ import { RouteObject, useNavigate } from "react-router-dom"; import BlueWallet from "Icons/BlueWallet"; import ConnectLNC from "Pages/settings/wallet/LNC"; -import ConnectLNDHub from "./wallet/LNDHub"; +import ConnectLNDHub from "Pages/settings/wallet/LNDHub"; +import ConnectNostrWallet from "Pages/settings/wallet/NWC"; +import NostrIcon from "Icons/Nostrich"; const WalletSettings = () => { const navigate = useNavigate(); @@ -19,12 +21,14 @@ const WalletSettings = () => {

LND with LNC

- { -
navigate("/settings/wallet/lndhub")}> - -

LNDHub

-
- } +
navigate("/settings/wallet/lndhub")}> + +

LNDHub

+
+
navigate("/settings/wallet/nwc")}> + +

Nostr Wallet Connect

+
); @@ -45,4 +49,8 @@ export const WalletSettingsRoutes = [ path: "/settings/wallet/lndhub", element: , }, + { + path: "/settings/wallet/nwc", + element: , + }, ] as Array; diff --git a/packages/app/src/Pages/settings/wallet/NWC.tsx b/packages/app/src/Pages/settings/wallet/NWC.tsx new file mode 100644 index 00000000..aa5cc505 --- /dev/null +++ b/packages/app/src/Pages/settings/wallet/NWC.tsx @@ -0,0 +1,70 @@ +import { useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import { v4 as uuid } from "uuid"; + +import AsyncButton from "Element/AsyncButton"; +import { unwrap } from "Util"; +import { WalletConfig, WalletKind, Wallets } from "Wallet"; +import { useNavigate } from "react-router-dom"; +import { NostrConnectWallet } from "Wallet/NostrWalletConnect"; + +const ConnectNostrWallet = () => { + const navigate = useNavigate(); + const { formatMessage } = useIntl(); + const [config, setConfig] = useState(); + const [error, setError] = useState(); + + async function tryConnect(config: string) { + try { + const connection = new NostrConnectWallet(config); + await connection.login(); + const info = await connection.getInfo(); + + const newWallet = { + id: uuid(), + kind: WalletKind.NWC, + active: true, + info, + data: config, + } as WalletConfig; + Wallets.add(newWallet); + + navigate("/wallet"); + } catch (e) { + if (e instanceof Error) { + setError((e as Error).message); + } else { + setError( + formatMessage({ + defaultMessage: "Unknown error", + }) + ); + } + } + } + + return ( + <> +

+ +

+
+
+ setConfig(e.target.value)} + /> +
+ tryConnect(unwrap(config))} disabled={!config}> + + +
+ {error && {error}} + + ); +}; + +export default ConnectNostrWallet; diff --git a/packages/app/src/System/EventBuilder.ts b/packages/app/src/System/EventBuilder.ts index 6c4b9039..1efd4b5f 100644 --- a/packages/app/src/System/EventBuilder.ts +++ b/packages/app/src/System/EventBuilder.ts @@ -1,6 +1,6 @@ import { EventKind, HexKey, NostrPrefix, RawEvent } from "@snort/nostr"; import { HashtagRegex } from "Const"; -import { parseNostrLink, unixNow } from "Util"; +import { getPublicKey, parseNostrLink, unixNow } from "Util"; import { EventExt } from "./EventExt"; export class EventBuilder { @@ -68,7 +68,7 @@ export class EventBuilder { * @param pk Private key to sign event with */ async buildAndSign(pk: HexKey) { - const ev = this.build(); + const ev = this.pubKey(getPublicKey(pk)).build(); await EventExt.sign(ev, pk); return ev; } diff --git a/packages/app/src/Util.ts b/packages/app/src/Util.ts index 2518be3c..19b70110 100644 --- a/packages/app/src/Util.ts +++ b/packages/app/src/Util.ts @@ -22,6 +22,10 @@ export const sha256 = (str: string | Uint8Array): u256 => { return secp.utils.bytesToHex(hash(str)); }; +export function getPublicKey(privKey: HexKey) { + return secp.utils.bytesToHex(secp.schnorr.getPublicKey(privKey)); +} + export async function openFile(): Promise { return new Promise(resolve => { const elm = document.createElement("input"); diff --git a/packages/app/src/Wallet/NostrWalletConnect.ts b/packages/app/src/Wallet/NostrWalletConnect.ts new file mode 100644 index 00000000..f561bffa --- /dev/null +++ b/packages/app/src/Wallet/NostrWalletConnect.ts @@ -0,0 +1,146 @@ +import { Connection, RawEvent } from "@snort/nostr"; +import { EventBuilder } from "System"; +import { EventExt } from "System/EventExt"; +import { LNWallet, WalletError, WalletErrorCode, WalletInfo, WalletInvoice } from "Wallet"; + +interface WalletConnectConfig { + relayUrl: string; + walletPubkey: string; + secret: string; +} + +interface QueueObj { + resolve: (o: string) => void; + reject: (e: Error) => void; +} + +export class NostrConnectWallet implements LNWallet { + #config: WalletConnectConfig; + #conn?: Connection; + #commandQueue: Map; + + constructor(cfg: string) { + this.#config = NostrConnectWallet.parseConfigUrl(cfg); + this.#commandQueue = new Map(); + } + + static parseConfigUrl(url: string) { + const uri = new URL(url.replace("nostrwalletconnect://", "http://").replace("nostr+walletconnect://", "http://")); + return { + relayUrl: uri.searchParams.get("relay"), + walletPubkey: uri.host, + secret: uri.searchParams.get("secret"), + } as WalletConnectConfig; + } + + isReady(): boolean { + return true; + } + + async getInfo() { + await this.login(); + return await new Promise((resolve, reject) => { + this.#commandQueue.set("info", { + resolve: (o: string) => { + resolve({ + alias: "NWC", + chains: o.split(" "), + } as WalletInfo); + }, + reject, + }); + this.#conn?.QueueReq(["REQ", "info", { kinds: [13194], limit: 1 }], () => { + // ignored + }); + }); + } + + async login() { + if (this.#conn) return true; + + return await new Promise(resolve => { + this.#conn = new Connection(this.#config.relayUrl, { read: true, write: true }); + this.#conn.OnConnected = () => resolve(true); + this.#conn.OnEvent = (s, e) => { + this.#onReply(s, e); + }; + this.#conn.Connect(); + }); + } + + async close() { + this.#conn?.Close(); + return true; + } + + async getBalance() { + return 0; + } + + createInvoice() { + return Promise.reject(new WalletError(WalletErrorCode.GeneralError, "Not implemented")); + } + + async payInvoice(pr: string) { + await this.login(); + return await this.#rpc("pay_invoice", { + invoice: pr, + }); + } + + getInvoices() { + return Promise.resolve([]); + } + + async #onReply(sub: string, e: RawEvent) { + if (sub === "info") { + const pending = this.#commandQueue.get("info"); + if (!pending) { + throw new WalletError(WalletErrorCode.GeneralError, "No pending info command found"); + } + pending.resolve(e.content); + this.#commandQueue.delete("info"); + return; + } + + if (e.kind !== 23195) { + throw new WalletError(WalletErrorCode.GeneralError, "Unknown event kind"); + } + + const replyTo = e.tags.find(a => a[0] === "e"); + if (!replyTo) { + throw new WalletError(WalletErrorCode.GeneralError, "Missing e-tag in command response"); + } + + const pending = this.#commandQueue.get(replyTo[1]); + if (!pending) { + throw new WalletError(WalletErrorCode.GeneralError, "No pending command found"); + } + + const body = JSON.parse(await EventExt.decryptData(e.content, this.#config.secret, this.#config.walletPubkey)); + pending.resolve(body); + this.#commandQueue.delete(replyTo[1]); + } + + async #rpc(method: string, params: Record) { + if (!this.#conn) throw new WalletError(WalletErrorCode.GeneralError, "Not implemented"); + + const payload = JSON.stringify({ + method, + params, + }); + const eb = new EventBuilder(); + eb.kind(23194) + .content(await EventExt.encryptData(payload, this.#config.walletPubkey, this.#config.secret)) + .tag(["p", this.#config.walletPubkey]); + + const evCommand = await eb.buildAndSign(this.#config.secret); + await this.#conn.SendAsync(evCommand); + /*return await new Promise((resolve, reject) => { + this.#commandQueue.set(evCommand.id, { + resolve, reject + }) + })*/ + return {} as T; + } +} diff --git a/packages/app/src/Wallet/index.ts b/packages/app/src/Wallet/index.ts index c0632777..29b53e76 100644 --- a/packages/app/src/Wallet/index.ts +++ b/packages/app/src/Wallet/index.ts @@ -3,12 +3,14 @@ import { useSyncExternalStore } from "react"; import { decodeInvoice, unwrap } from "Util"; import { LNCWallet } from "./LNCWallet"; import LNDHubWallet from "./LNDHub"; +import { NostrConnectWallet } from "./NostrWalletConnect"; import { setupWebLNWalletConfig, WebLNWallet } from "./WebLN"; export enum WalletKind { LNDHub = 1, LNC = 2, WebLN = 3, + NWC = 4, } export enum WalletErrorCode { @@ -246,6 +248,9 @@ export class WalletStore { case WalletKind.LNDHub: { return new LNDHubWallet(unwrap(cfg.data)); } + case WalletKind.NWC: { + return new NostrConnectWallet(unwrap(cfg.data)); + } } } } diff --git a/packages/app/src/lang.json b/packages/app/src/lang.json index 79a78a93..daee238d 100644 --- a/packages/app/src/lang.json +++ b/packages/app/src/lang.json @@ -57,6 +57,9 @@ "1Mo59U": { "defaultMessage": "Are you sure you want to remove this note from bookmarks?" }, + "1R43+L": { + "defaultMessage": "Enter Nostr Wallet Connect config" + }, "1c4YST": { "defaultMessage": "Connected to: {node} 🎉" }, @@ -307,15 +310,15 @@ "Ebl/B2": { "defaultMessage": "Translate to {lang}" }, + "EcZF24": { + "defaultMessage": "Custom Relays" + }, "EcglP9": { "defaultMessage": "Key" }, "EnCOBJ": { "defaultMessage": "Buy" }, - "EqRgFp": { - "defaultMessage": "Some clients will only allow zaps on your notes" - }, "Eqjl5K": { "defaultMessage": "Only Snort and our integration partner identifier gives you a colorful domain name, but you are welcome to use other services too." }, @@ -724,6 +727,9 @@ "c35bj2": { "defaultMessage": "If you have an enquiry about your NIP-05 order please DM {link}" }, + "c3g2hL": { + "defaultMessage": "Broadcast Again" + }, "cPIKU2": { "defaultMessage": "Following" }, @@ -1056,6 +1062,9 @@ "tOdNiY": { "defaultMessage": "Dark" }, + "th5lxp": { + "defaultMessage": "Send note to a subset of your write relays" + }, "thnRpU": { "defaultMessage": "Getting NIP-05 verified can help:" }, @@ -1080,9 +1089,6 @@ "ut+2Cd": { "defaultMessage": "Get a partner identifier" }, - "v48Uwm": { - "defaultMessage": "OnlyZaps" - }, "vOKedj": { "defaultMessage": "{n,plural,=1{& {n} other} other{& {n} others}}" }, diff --git a/packages/app/src/translations/en.json b/packages/app/src/translations/en.json index b03ac1b0..3d824184 100644 --- a/packages/app/src/translations/en.json +++ b/packages/app/src/translations/en.json @@ -18,6 +18,7 @@ "0yO7wF": "{n} secs", "1A7TZk": "What is Snort and how does it work?", "1Mo59U": "Are you sure you want to remove this note from bookmarks?", + "1R43+L": "Enter Nostr Wallet Connect config", "1c4YST": "Connected to: {node} 🎉", "1iQ8GN": "Toggle Preview", "1nYUGC": "{n} Following", @@ -100,9 +101,9 @@ "EPYwm7": "Your private key is your password. If you lose this key, you will lose access to your account! Copy it and keep it in a safe place. There is no way to reset your private key.", "EWyQH5": "Global", "Ebl/B2": "Translate to {lang}", + "EcZF24": "Custom Relays", "EcglP9": "Key", "EnCOBJ": "Buy", - "EqRgFp": "Some clients will only allow zaps on your notes", "Eqjl5K": "Only Snort and our integration partner identifier gives you a colorful domain name, but you are welcome to use other services too.", "F+B3x1": "We have also partnered with nostrplebs.com to give you more options", "F3l7xL": "Add Account", @@ -237,6 +238,7 @@ "bxv59V": "Just now", "c+oiJe": "Install Extension", "c35bj2": "If you have an enquiry about your NIP-05 order please DM {link}", + "c3g2hL": "Broadcast Again", "cPIKU2": "Following", "cQfLWb": "URL..", "cWx9t8": "Mute all", @@ -346,6 +348,7 @@ "sWnYKw": "Snort is designed to have a similar experience to Twitter.", "svOoEH": "Name-squatting and impersonation is not allowed. Snort and our partners reserve the right to terminate your handle (not your account - nobody can take that away) for violating this rule.", "tOdNiY": "Dark", + "th5lxp": "Send note to a subset of your write relays", "thnRpU": "Getting NIP-05 verified can help:", "ttxS0b": "Supporter Badge", "u/vOPu": "Paid", @@ -354,7 +357,6 @@ "uSV4Ti": "Reposts need to be manually confirmed", "usAvMr": "Edit Profile", "ut+2Cd": "Get a partner identifier", - "v48Uwm": "OnlyZaps", "vOKedj": "{n,plural,=1{& {n} other} other{& {n} others}}", "vU71Ez": "Paying with {wallet}", "vZ4quW": "NIP-05 is a DNS based verification spec which helps to validate you as a real user.", @@ -388,4 +390,4 @@ "zjJZBd": "You're ready!", "zonsdq": "Failed to load LNURL service", "zvCDao": "Automatically show latest notes" -} +} \ No newline at end of file