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