diff --git a/package.json b/package.json index ea2d6b0121..c4c90f19a8 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "dependencies": { "@bitcoin-design/bitcoin-icons-react": "^0.1.10", "@bitcoinerlab/secp256k1": "^1.0.5", - "@getalby/sdk": "^3.1.0", + "@getalby/sdk": "^3.2.2", "@headlessui/react": "^1.7.16", "@lightninglabs/lnc-web": "^0.2.4-alpha", "@noble/curves": "^1.1.0", @@ -104,6 +104,7 @@ "@types/webextension-polyfill": "^0.10.5", "@typescript-eslint/eslint-plugin": "^6.8.0", "@typescript-eslint/parser": "^6.8.0", + "@webbtc/webln-types": "^3.0.0", "autoprefixer": "^10.4.16", "buffer": "^6.0.3", "clean-webpack-plugin": "^4.0.0", diff --git a/src/app/router/connectorRoutes.tsx b/src/app/router/connectorRoutes.tsx index 417770ed69..c094d323f4 100644 --- a/src/app/router/connectorRoutes.tsx +++ b/src/app/router/connectorRoutes.tsx @@ -14,6 +14,7 @@ import ConnectUmbrel from "@screens/connectors/ConnectUmbrel"; import { Route } from "react-router-dom"; import i18n from "~/i18n/i18nConfig"; +import ConnectNWC from "~/app/screens/connectors/ConnectNWC"; import ConnectCommando from "../screens/connectors/ConnectCommando"; import btcpay from "/static/assets/icons/btcpay.svg"; import citadel from "/static/assets/icons/citadel.png"; @@ -27,6 +28,7 @@ import lnbits from "/static/assets/icons/lnbits.png"; import lnd from "/static/assets/icons/lnd.png"; import lndhubGo from "/static/assets/icons/lndhub_go.png"; import mynode from "/static/assets/icons/mynode.png"; +import nwc from "/static/assets/icons/nwc.svg"; import raspiblitz from "/static/assets/icons/raspiblitz.png"; import start9 from "/static/assets/icons/start9.png"; import umbrel from "/static/assets/icons/umbrel.png"; @@ -149,6 +151,12 @@ const connectorMap: { [key: string]: ConnectorRoute } = { title: i18n.t("translation:choose_connector.btcpay.title"), logo: btcpay, }, + nwc: { + path: "nwc", + element: , + title: i18n.t("translation:choose_connector.nwc.title"), + logo: nwc, + }, }; function getDistribution(key: string): ConnectorRoute { @@ -188,6 +196,7 @@ const distributionMap: { [key: string]: { logo: string; children: Route[] } } = connectorMap["lnc"], connectorMap["commando"], connectorMap["lnbits"], + connectorMap["nwc"], ], }, mynode: { @@ -235,6 +244,7 @@ function getConnectorRoutes(): ConnectorRoute[] { getDistribution("mynode"), getDistribution("start9"), getDistribution("raspiblitz"), + connectorMap["nwc"], ]; } diff --git a/src/app/screens/ReceiveInvoice/index.tsx b/src/app/screens/ReceiveInvoice/index.tsx index 02242e8e2c..5460dc8472 100644 --- a/src/app/screens/ReceiveInvoice/index.tsx +++ b/src/app/screens/ReceiveInvoice/index.tsx @@ -228,9 +228,11 @@ function ReceiveInvoice() { {t("title")} {invoice ? ( - - {renderInvoice()} - +
+ + {renderInvoice()} + +
) : (
diff --git a/src/app/screens/connectors/ConnectNWC/index.tsx b/src/app/screens/connectors/ConnectNWC/index.tsx new file mode 100644 index 0000000000..5aa1111f00 --- /dev/null +++ b/src/app/screens/connectors/ConnectNWC/index.tsx @@ -0,0 +1,125 @@ +import ConnectorForm from "@components/ConnectorForm"; +import TextField from "@components/form/TextField"; +import ConnectionErrorToast from "@components/toasts/ConnectionErrorToast"; +import { useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import toast from "~/app/components/Toast"; +import msg from "~/common/lib/msg"; + +import logo from "/static/assets/icons/nwc.svg"; + +export default function ConnectNWC() { + const navigate = useNavigate(); + const { t } = useTranslation("translation", { + keyPrefix: "choose_connector.nwc", + }); + const [formData, setFormData] = useState({ + nostrWalletConnectUrl: "", + }); + const [loading, setLoading] = useState(false); + + function handleChange(event: React.ChangeEvent) { + setFormData({ + ...formData, + [event.target.name]: event.target.value.trim(), + }); + } + + function getConnectorType() { + return "nwc"; + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setLoading(true); + const { nostrWalletConnectUrl } = formData; + const account = { + name: "NWC", + config: { + nostrWalletConnectUrl, + }, + connector: getConnectorType(), + }; + + try { + const validation = await msg.request("validateAccount", account); + if (validation.valid) { + const addResult = await msg.request("addAccount", account); + if (addResult.accountId) { + await msg.request("selectAccount", { + id: addResult.accountId, + }); + navigate("/test-connection"); + } + } else { + console.error(validation); + toast.error( + + ); + } + } catch (e) { + console.error(e); + let message = t("page.errors.connection_failed"); + if (e instanceof Error) { + message += `\n\n${e.message}`; + } + toast.error(message); + } + setLoading(false); + } + + return ( + + + + } + description={ + , + // eslint-disable-next-line react/jsx-key + , + // eslint-disable-next-line react/jsx-key + , + ]} + /> + } + logo={logo} + submitLoading={loading} + submitDisabled={formData.nostrWalletConnectUrl === ""} + onSubmit={handleSubmit} + > +
+ +
+
+ ); +} diff --git a/src/extension/background-script/actions/ln/checkPayment.ts b/src/extension/background-script/actions/ln/checkPayment.ts index 0c3051b95b..a187f59edb 100644 --- a/src/extension/background-script/actions/ln/checkPayment.ts +++ b/src/extension/background-script/actions/ln/checkPayment.ts @@ -3,7 +3,10 @@ import { Message } from "~/types"; import state from "../../state"; const checkPayment = async (message: Message) => { - if (typeof message.args.paymentHash !== "string") { + if ( + typeof message.args.paymentHash !== "string" || + !message.args.paymentHash + ) { return { error: "Payment hash missing.", }; diff --git a/src/extension/background-script/connectors/connector.example b/src/extension/background-script/connectors/connector.example deleted file mode 100644 index 06a9aaf6c6..0000000000 --- a/src/extension/background-script/connectors/connector.example +++ /dev/null @@ -1,62 +0,0 @@ -import Connector, { - SendPaymentArgs, - SendPaymentResponse, - CheckPaymentArgs, - CheckPaymentResponse, - GetInfoResponse, - GetBalanceResponse, - MakeInvoiceArgs, - MakeInvoiceResponse, - SignMessageArgs, - SignMessageResponse, - VerifyMessageArgs, - VerifyMessageResponse, -} from "./connector.interface"; - -interface Config {} - -class ConnectorExample implements Connector { - config: Config; - - constructor(config: Config) { - this.config = config; - } - - init() { - // add your own implementation or return Promise.resolve(); - } - - unload() { - // add your own implementation or return Promise.resolve(); - } - - getInfo(): Promise { - // Add your own implementation. - } - - getBalance(): Promise { - // Add your own implementation. - } - - sendPayment(args: SendPaymentArgs): Promise { - // Add your own implementation. - } - - checkPayment(args: CheckPaymentArgs): Promise { - // Add your own implementation. - } - - signMessage(args: SignMessageArgs): Promise { - // Add your own implementation. - } - - verifyMessage(args: VerifyMessageArgs): Promise { - // Add your own implementation. - } - - makeInvoice(args: MakeInvoiceArgs): Promise { - // Add your own implementation. - } -} - -export default ConnectorExample; diff --git a/src/extension/background-script/connectors/index.ts b/src/extension/background-script/connectors/index.ts index f98ee5b268..95bbaef680 100644 --- a/src/extension/background-script/connectors/index.ts +++ b/src/extension/background-script/connectors/index.ts @@ -12,6 +12,7 @@ import NativeCitadel from "./nativecitadel"; import NativeLnBits from "./nativelnbits"; import NativeLnd from "./nativelnd"; import NativeLndHub from "./nativelndhub"; +import NWC from "./nwc"; /* const initialize = (account, password) => { @@ -36,6 +37,7 @@ const connectors = { nativecitadel: NativeCitadel, commando: Commando, alby: Alby, + nwc: NWC, }; export default connectors; diff --git a/src/extension/background-script/connectors/nwc.ts b/src/extension/background-script/connectors/nwc.ts new file mode 100644 index 0000000000..5f065f77d1 --- /dev/null +++ b/src/extension/background-script/connectors/nwc.ts @@ -0,0 +1,191 @@ +import { webln } from "@getalby/sdk"; +import { NostrWebLNProvider } from "@getalby/sdk/dist/webln"; +import lightningPayReq from "bolt11"; +import Hex from "crypto-js/enc-hex"; +import SHA256 from "crypto-js/sha256"; +import { Account } from "~/types"; +import Connector, { + CheckPaymentArgs, + CheckPaymentResponse, + ConnectPeerArgs, + ConnectPeerResponse, + ConnectorTransaction, + GetBalanceResponse, + GetInfoResponse, + GetTransactionsResponse, + KeysendArgs, + MakeInvoiceArgs, + MakeInvoiceResponse, + SendPaymentArgs, + SendPaymentResponse, + SignMessageArgs, + SignMessageResponse, +} from "./connector.interface"; + +interface Config { + nostrWalletConnectUrl: string; +} + +class NWCConnector implements Connector { + config: Config; + nwc: NostrWebLNProvider; + + get supportedMethods() { + return [ + "getInfo", + "makeInvoice", + "sendPayment", + "sendPaymentAsync", + "getBalance", + "keysend", + "getTransactions", + ]; + } + + constructor(account: Account, config: Config) { + this.config = config; + this.nwc = new webln.NostrWebLNProvider({ + nostrWalletConnectUrl: this.config.nostrWalletConnectUrl, + }); + } + + async init() { + return this.nwc.enable(); + } + + async unload() { + this.nwc.close(); + } + + async getInfo(): Promise { + const info = await this.nwc.getInfo(); + return { + data: info.node, + }; + } + + async getBalance(): Promise { + const balance = await this.nwc.getBalance(); + return { + data: { balance: balance.balance, currency: "BTC" }, + }; + } + + async getTransactions(): Promise { + const listTransactionsResponse = await this.nwc.listTransactions({ + unpaid: false, + limit: 50, // restricted by relay max event payload size + }); + + const transactions: ConnectorTransaction[] = + listTransactionsResponse.transactions.map( + (transaction, index): ConnectorTransaction => ({ + id: `${index}`, + memo: transaction.description, + preimage: transaction.preimage, + payment_hash: transaction.payment_hash, + settled: true, + settleDate: new Date(transaction.settled_at).getTime(), + totalAmount: transaction.amount, + type: transaction.type == "incoming" ? "received" : "sent", + }) + ); + return { + data: { + transactions, + }, + }; + } + + async makeInvoice(args: MakeInvoiceArgs): Promise { + const invoice = await this.nwc.makeInvoice({ + amount: args.amount, + defaultMemo: args.memo, + }); + + const decodedInvoice = lightningPayReq.decode(invoice.paymentRequest); + const paymentHash = decodedInvoice.tags.find( + (tag) => tag.tagName === "payment_hash" + )?.data as string | undefined; + if (!paymentHash) { + throw new Error("Could not find payment hash in invoice"); + } + + return { + data: { + paymentRequest: invoice.paymentRequest, + rHash: paymentHash, + }, + }; + } + + async sendPayment(args: SendPaymentArgs): Promise { + const invoice = lightningPayReq.decode(args.paymentRequest); + const paymentHash = invoice.tags.find( + (tag) => tag.tagName === "payment_hash" + )?.data as string | undefined; + if (!paymentHash) { + throw new Error("Could not find payment hash in invoice"); + } + + const response = await this.nwc.sendPayment(args.paymentRequest); + return { + data: { + preimage: response.preimage, + paymentHash, + route: { + // TODO: how to get amount paid for zero-amount invoices? + total_amt: parseInt(invoice.millisatoshis || "0") / 1000, + // TODO: How to get fees from WebLN? + total_fees: 0, + }, + }, + }; + } + + async keysend(args: KeysendArgs): Promise { + const data = await this.nwc.keysend({ + destination: args.pubkey, + amount: args.amount, + customRecords: args.customRecords, + }); + + const paymentHash = SHA256(data.preimage).toString(Hex); + + return { + data: { + preimage: data.preimage, + paymentHash, + + route: { + total_amt: args.amount, + // TODO: How to get fees from WebLN? + total_fees: 0, + }, + }, + }; + } + + async checkPayment(args: CheckPaymentArgs): Promise { + const response = await this.nwc.lookupInvoice({ + paymentHash: args.paymentHash, + }); + + return { + data: { + paid: response.paid, + preimage: response.preimage, + }, + }; + } + + signMessage(args: SignMessageArgs): Promise { + throw new Error("Method not implemented."); + } + + connectPeer(args: ConnectPeerArgs): Promise { + throw new Error("Method not implemented."); + } +} + +export default NWCConnector; diff --git a/src/extension/providers/webln/index.ts b/src/extension/providers/webln/index.ts index 2ed7815354..1b2e85d6d5 100644 --- a/src/extension/providers/webln/index.ts +++ b/src/extension/providers/webln/index.ts @@ -1,11 +1,5 @@ import ProviderBase from "~/extension/providers/providerBase"; -declare global { - interface Window { - webln: WebLNProvider; - } -} - type RequestInvoiceArgs = { amount?: string | number; defaultAmount?: string | number; diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 26fc7a1d51..906eaf96ba 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -285,6 +285,19 @@ "errors": { "connection_failed": "Connection failed. Is your Core Lightning node online and using the commando plugin?" } + }, + "nwc": { + "title": "Nostr Wallet Connect", + "page": { + "instructions": "Paste a NWC connection string from <0>Alby Nostr Wallet Connect, <1>Umbrel Nostr Wallet Connect, or <2>Mutiny Wallet", + "url": { + "label": "Nostr Wallet Connect URL", + "placeholder": "nostr+walletconnect://69effe..." + }, + "errors": { + "connection_failed": "Connection failed. Is your Core Lightning node online and using the commando plugin?" + } + } } }, "distributions": { diff --git a/static/assets/icons/nwc.svg b/static/assets/icons/nwc.svg new file mode 100644 index 0000000000..7d9c015292 --- /dev/null +++ b/static/assets/icons/nwc.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 8d6a01111a..5694c21c67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -666,10 +666,10 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.52.0.tgz#78fe5f117840f69dc4a353adf9b9cd926353378c" integrity sha512-mjZVbpaeMZludF2fsWLD0Z9gCref1Tk4i9+wddjRvpUNqqcndPkBD09N/Mapey0b3jaXbLm2kICwFv2E64QinA== -"@getalby/sdk@^3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@getalby/sdk/-/sdk-3.1.0.tgz#49a6d7b292f3c6ab1c37e72422aa0f0ec8f43226" - integrity sha512-1WwwMfrCRtlUv3BnT/rqYiE5giztH5ZxfT1fDwhaJGeC8EJXxGjFBbaUhE0Wq98Fcs/hKoGM4gSmp3UHFDuQxg== +"@getalby/sdk@^3.2.2": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@getalby/sdk/-/sdk-3.2.2.tgz#a640fef78f4462fd8924eab9ab8a8f9a0339a11e" + integrity sha512-G4Ooteo/5D6SXB+y8OK8gxXWALGh4HFgq8ZqT3rBMo3FV7U/fDjf+/jn/SMsJ7ub/nEzUBBTGdfARdVoYqMvSQ== dependencies: events "^3.3.0" nostr-tools "^1.17.0" @@ -2102,6 +2102,11 @@ "@webassemblyjs/ast" "1.11.6" "@xtuc/long" "4.2.2" +"@webbtc/webln-types@^3.0.0": + version "3.0.0" + resolved "https://registry.yarnpkg.com/@webbtc/webln-types/-/webln-types-3.0.0.tgz#448b2138423865087ba8859e9e6430fc2463b864" + integrity sha512-aXfTHLKz5lysd+6xTeWl+qHNh/p3qVYbeLo+yDN5cUDmhie2ZoGvkppfWxzbGkcFBzb6dJyQ2/i2cbmDHas+zQ== + "@webpack-cli/configtest@^2.1.1": version "2.1.1" resolved "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz"