diff --git a/package.json b/package.json index 979ee979c3..c1c3843e87 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "html5-qrcode": "^2.2.3", "i18next": "^21.10.0", "i18next-browser-languagedetector": "^7.0.1", + "lnmessage": "^0.0.11", "lodash.merge": "^4.6.2", "lodash.pick": "^4.4.0", "pubsub-js": "^1.9.4", diff --git a/src/app/router/connectorRoutes.tsx b/src/app/router/connectorRoutes.tsx index 79612cd280..8a72c1f235 100644 --- a/src/app/router/connectorRoutes.tsx +++ b/src/app/router/connectorRoutes.tsx @@ -13,9 +13,11 @@ import NewWallet from "@screens/connectors/NewWallet"; import i18n from "~/i18n/i18nConfig"; import { translationI18nNamespace } from "~/i18n/namespaces"; +import ConnectCommando from "../screens/connectors/ConnectCommando"; import alby from "/static/assets/icons/alby.png"; import btcpay from "/static/assets/icons/btcpay.svg"; import citadel from "/static/assets/icons/citadel.png"; +import core_ln from "/static/assets/icons/core_ln.svg"; import eclair from "/static/assets/icons/eclair.jpg"; import galoyBitcoinBeach from "/static/assets/icons/galoy_bitcoin_beach.png"; import galoyBitcoinJungle from "/static/assets/icons/galoy_bitcoin_jungle.png"; @@ -55,6 +57,19 @@ function getConnectorRoutes() { ), logo: lnd, }, + { + path: "commando", + element: , + title: i18n.t( + "choose_connector.commando.title", + translationI18nNamespace + ), + description: i18n.t( + "choose_connector.commando.description", + translationI18nNamespace + ), + logo: core_ln, + }, { path: "lnbits", element: , diff --git a/src/app/screens/connectors/ConnectCommando/index.tsx b/src/app/screens/connectors/ConnectCommando/index.tsx new file mode 100644 index 0000000000..fc8b736eb1 --- /dev/null +++ b/src/app/screens/connectors/ConnectCommando/index.tsx @@ -0,0 +1,205 @@ +import { + HiddenIcon, + VisibleIcon, +} from "@bitcoin-design/bitcoin-icons-react/outline"; +import Button from "@components/Button"; +import ConnectorForm from "@components/ConnectorForm"; +import TextField from "@components/form/TextField"; +import ConnectionErrorToast from "@components/toasts/ConnectionErrorToast"; +import * as secp256k1 from "@noble/secp256k1"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { toast } from "react-toastify"; +import utils from "~/common/lib/utils"; + +export default function ConnectCommando() { + const navigate = useNavigate(); + const { t } = useTranslation("translation", { + keyPrefix: `choose_connector.commando`, + }); + const { t: tCommon } = useTranslation("common"); + const [formData, setFormData] = useState({ + host: "", + pubkey: "", + rune: "", + port: 9735, + privateKey: generateCommandoPrivateKey(), + proxy: "wss://lnproxy.getalby.com", + }); + const [loading, setLoading] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); + const [commandoPrivateKeyVisible, setCommandoPrivateKeyVisible] = + useState(false); + + function handleChange(event: React.ChangeEvent) { + setFormData({ + ...formData, + [event.target.name]: event.target.value.trim(), + }); + } + + function getConnectorType() { + return "commando"; + } + + function generateCommandoPrivateKey(): string { + const privKey = secp256k1.utils.randomPrivateKey(); + return secp256k1.utils.bytesToHex(privKey); + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setLoading(true); + const host = formData.host; + const pubkey = formData.pubkey; + const rune = formData.rune; + const port = formData.port; + const wsProxy = formData.proxy; + const privateKey = formData.privateKey; + const account = { + name: "commando", + config: { + host, + pubkey, + rune, + port, + wsProxy, + privateKey, + }, + connector: getConnectorType(), + }; + + try { + const validation = await utils.call("validateAccount", account); + if (validation.valid) { + const addResult = await utils.call("addAccount", account); + if (addResult.accountId) { + await utils.call("selectAccount", { + id: addResult.accountId, + }); + navigate("/test-connection"); + } + } else { + console.error(validation); + toast.error( + + ); + } + } catch (e) { + console.error(e); + let message = t("errors.connection_failed"); + if (e instanceof Error) { + message += `\n\n${e.message}`; + } + toast.error(message); + } + setLoading(false); + } + + return ( + +
+ +
+
+ +
+
+ +
+
+ +
+ + } + /> + + + )} +
+ ); +} diff --git a/src/extension/background-script/connectors/commando.ts b/src/extension/background-script/connectors/commando.ts new file mode 100644 index 0000000000..e07e274c0f --- /dev/null +++ b/src/extension/background-script/connectors/commando.ts @@ -0,0 +1,316 @@ +import Hex from "crypto-js/enc-hex"; +import UTF8 from "crypto-js/enc-utf8"; +import LnMessage from "lnmessage"; +import { v4 as uuidv4 } from "uuid"; + +import Connector, { + CheckPaymentArgs, + CheckPaymentResponse, + ConnectorInvoice, + ConnectPeerArgs, + ConnectPeerResponse, + GetBalanceResponse, + GetInfoResponse, + GetInvoicesResponse, + KeysendArgs, + MakeInvoiceArgs, + MakeInvoiceResponse, + SendPaymentArgs, + SendPaymentResponse, + SignMessageArgs, + SignMessageResponse, +} from "./connector.interface"; + +interface Config { + host: string; + port: number; + rune: string; + pubkey: string; + wsProxy: string; + privateKey: string; +} + +type CommandoGetInfoResponse = { + alias: string; + id: string; + color: string; +}; +type CommandoSignMessageResponse = { + zbase: string; +}; +type CommandoMakeInvoiceResponse = { + bolt11: string; + payment_hash: string; + payment_secret: string; +}; +type CommandoChannel = { + peer_id: string; + channel_sat: number; + amount_msat: number; + funding_txid: string; + funding_output: number; + connected: boolean; + state: string; +}; +type CommandoListFundsResponse = { + channels: CommandoChannel[]; +}; +type CommandoListInvoicesResponse = { + invoices: CommandoInvoice[]; +}; +type CommandoPayInvoiceResponse = { + payment_preimage: string; + payment_hash: string; + msatoshi: number; + msatoshi_sent: number; +}; +type CommandoListInvoiceResponse = { + invoices: CommandoInvoice[]; +}; + +type CommandoInvoice = { + label: string; + status: string; + description: string; + msatoshi: number; + bolt11: string; + payment_preimage: string; + paid_at: number; + payment_hash: string; +}; +export default class Commando implements Connector { + config: Config; + ln: LnMessage; + + constructor(config: Config) { + this.config = config; + this.ln = new LnMessage({ + remoteNodePublicKey: this.config.pubkey, + wsProxy: this.config.wsProxy, + ip: this.config.host, + port: this.config.port || 9735, + privateKey: this.config.privateKey, + // logger: { + // info: console.log, + // warn: console.warn, + // error: console.error + // }, + }); + } + + async init() { + // initiate the connection to the remote node + await this.ln.connect(); + } + + async unload() { + await this.ln.disconnect(); + } + + async connectPeer( + args: ConnectPeerArgs + ): Promise { + return this.ln + .commando({ + method: "connect", + params: { + id: args.pubkey, + host: args.host, + }, + rune: this.config.rune, + }) + .then((resp) => { + return { + data: true, + }; + }) + .catch((err) => { + return new Error(err); + }); + } + + async getInvoices(): Promise { + return this.ln + .commando({ + method: "listinvoices", + params: {}, + rune: this.config.rune, + }) + .then((resp) => { + const parsed = resp as CommandoListInvoicesResponse; + return { + data: { + invoices: parsed.invoices + .map( + (invoice, index): ConnectorInvoice => ({ + id: invoice.label, + memo: invoice.description, + settled: invoice.status === "paid", + preimage: invoice.payment_preimage, + settleDate: invoice.paid_at * 1000, + type: "received", + totalAmount: (invoice.msatoshi / 1000).toString(), + }) + ) + .filter((invoice) => invoice.settled) + .sort((a, b) => { + return b.settleDate - a.settleDate; + }), + }, + }; + }); + } + + async getInfo(): Promise { + const response = (await this.ln.commando({ + method: "getinfo", + params: {}, + rune: this.config.rune, + })) as CommandoGetInfoResponse; + return { + data: { + alias: response.alias, + pubkey: response.id, + color: response.color, + }, + }; + } + + async getBalance(): Promise { + const response = (await this.ln.commando({ + method: "listfunds", + params: {}, + rune: this.config.rune, + })) as CommandoListFundsResponse; + const lnBalance = response.channels.reduce( + (balance, channel) => balance + channel.channel_sat, + 0 + ); + return { + data: { + balance: lnBalance, + }, + }; + } + + async sendPayment(args: SendPaymentArgs): Promise { + return this.ln + .commando({ + method: "pay", + params: { + bolt11: args.paymentRequest, + }, + rune: this.config.rune, + }) + .then((resp) => { + const parsed = resp as CommandoPayInvoiceResponse; + return { + data: { + paymentHash: parsed.payment_hash, + preimage: parsed.payment_preimage, + route: { + total_amt: Math.floor(parsed.msatoshi_sent / 1000), + total_fees: Math.floor( + (parsed.msatoshi_sent - parsed.msatoshi) / 1000 + ), + }, + }, + }; + }); + } + + async keysend(args: KeysendArgs): Promise { + //hex encode the record values + const records_hex: Record = {}; + for (const key in args.customRecords) { + records_hex[key] = UTF8.parse(args.customRecords[key]).toString(Hex); + } + const boostagram: { [key: string]: string } = {}; + for (const key in args.customRecords) { + boostagram[key] = UTF8.parse(args.customRecords[key]).toString(Hex); + } + return this.ln + .commando({ + method: "keysend", + params: { + destination: args.pubkey, + msatoshi: args.amount * 1000, + extratlvs: boostagram, + }, + rune: this.config.rune, + }) + .then((resp) => { + const parsed = resp as CommandoPayInvoiceResponse; + return { + data: { + paymentHash: parsed.payment_hash, + preimage: parsed.payment_preimage, + route: { + total_amt: Math.floor(parsed.msatoshi_sent / 1000), + total_fees: Math.floor( + (parsed.msatoshi_sent - parsed.msatoshi) / 1000 + ), + }, + }, + }; + }); + } + + async checkPayment(args: CheckPaymentArgs): Promise { + return this.ln + .commando({ + method: "listinvoices", + params: { + payment_hash: args.paymentHash, + }, + rune: this.config.rune, + }) + .then((resp) => { + const parsed = resp as CommandoListInvoiceResponse; + return { + data: { + paid: parsed.invoices[0]?.status === "paid", + }, + }; + }); + } + + signMessage(args: SignMessageArgs): Promise { + return this.ln + .commando({ + method: "signmessage", + params: { + message: args.message, + }, + rune: this.config.rune, + }) + .then((resp) => { + const parsed = resp as CommandoSignMessageResponse; + return { + data: { + message: args.message, + signature: parsed.zbase, + }, + }; + }); + } + + async makeInvoice(args: MakeInvoiceArgs): Promise { + const label = uuidv4(); + const response = (await this.ln.commando({ + method: "invoice", + params: { + amount_msat: (args.amount as number) * 1000, + label: label, + description: args.memo, + }, + rune: this.config.rune, + })) as CommandoMakeInvoiceResponse; + return { + data: { + paymentRequest: response.bolt11, + rHash: response.payment_hash, + }, + }; + } +} diff --git a/src/extension/background-script/connectors/index.ts b/src/extension/background-script/connectors/index.ts index 5051195917..8c52847a82 100644 --- a/src/extension/background-script/connectors/index.ts +++ b/src/extension/background-script/connectors/index.ts @@ -1,4 +1,5 @@ import Citadel from "./citadel"; +import Commando from "./commando"; import Eclair from "./eclair"; import Galoy from "./galoy"; import LnBits from "./lnbits"; @@ -28,6 +29,7 @@ const connectors = { eclair: Eclair, citadel: Citadel, nativecitadel: NativeCitadel, + commando: Commando, }; export default connectors; diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 5d22c0861c..8554b3d498 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -288,6 +288,39 @@ "errors": { "connection_failed": "Connection failed. Is the BTCPay connection URL correct and accessible?" } + }, + "commando": { + "title": "Core Lightning", + "description": "Connect to your Core Lightning node", + "page": { + "title": "Connect to your Core Lightning node", + "instructions": "Make sure you have Core Lightning version 0.12.0 or newer, the commando plugin is running and your node is accessible over the Lightning Network. Create a rune by running 'lightning-cli commando-rune'." + }, + "host": { + "label": "Host" + }, + "pubkey": { + "label": "Public key" + }, + "rune": { + "label": "Rune" + }, + "port": { + "label": "Port" + }, + "proxy": { + "label": "Websocket proxy" + }, + "privKey": { + "label": "Local private key (autogenerated)" + }, + "config": { + "label": "Config data", + "placeholder": "config=https://your-btc-pay.org/lnd-config/212121/lnd.config" + }, + "errors": { + "connection_failed": "Connection failed. Is your Core Lightning node online and using the commando plugin?" + } } }, "home": { @@ -603,6 +636,7 @@ }, "common": { "password": "Password", + "advanced": "Advanced", "success": "Success", "error": "Error", "settings": "Settings", diff --git a/static/assets/icons/core_ln.svg b/static/assets/icons/core_ln.svg new file mode 100644 index 0000000000..a45cc54c8f --- /dev/null +++ b/static/assets/icons/core_ln.svg @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/tests/e2e/001-createWallets.spec.ts b/tests/e2e/001-createWallets.spec.ts index 8131274be8..116ae41d29 100644 --- a/tests/e2e/001-createWallets.spec.ts +++ b/tests/e2e/001-createWallets.spec.ts @@ -140,13 +140,49 @@ test.describe("Create or connect wallets", () => { ); await lndUrlField.type(restApiUrl); - const macroon = + const macaroon = "0201036c6e6402f801030a10b3bf6906c1937139ac0684ac4417139d1201301a160a0761646472657373120472656164120577726974651a130a04696e666f120472656164120577726974651a170a08696e766f69636573120472656164120577726974651a210a086d616361726f6f6e120867656e6572617465120472656164120577726974651a160a076d657373616765120472656164120577726974651a170a086f6666636861696e120472656164120577726974651a160a076f6e636861696e120472656164120577726974651a140a057065657273120472656164120577726974651a180a067369676e6572120867656e657261746512047265616400000620a3f810170ad9340a63074b6dded31ed83a7140fd26c7758856111583b7725b2b"; - const macroonField = await getByLabelText( + const macaroonField = await getByLabelText( $document, "Macaroon (HEX format)" ); - await macroonField.type(macroon); + await macaroonField.type(macaroon); + + await commonCreateWalletSuccessCheck({ page, $document }); + + await browser.close(); + }); + + test("successfully connects to Core Lightning", async () => { + const { browser, page, $document } = await commonCreateWalletUserCreate(); + + const createNewWalletButton = await getByText($document, "Core Lightning"); + createNewWalletButton.click(); + + // wait for the field label instead of headline (headline text already exists on the page before) + await findByText($document, "Host"); + + const host = "143.244.206.7"; + const pubkey = "032e2444c5bb14c5eb2bf8ebdfd102c162609956aa995b7c7d373ca378deedb5c7"; + const rune = "vrrgKshH1sPZ7wjQnCWjdEtB2PCcM48Gs05FuVPln8g9MTE="; + + const lndUrlField = await getByLabelText( + $document, + "Host" + ); + await lndUrlField.type(host); + + const pubkeyField = await getByLabelText( + $document, + "Public key" + ); + await pubkeyField.type(pubkey); + + const runeField = await getByLabelText( + $document, + "Rune" + ); + await runeField.type(rune) await commonCreateWalletSuccessCheck({ page, $document }); diff --git a/webpack.config.js b/webpack.config.js index 5f3c140af8..3bb1c92e0f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -134,6 +134,12 @@ var options = { test: /\.(woff|woff2|eot|ttf|otf)$/i, type: "asset/resource", }, + { + test: /\.m?js/, + resolve: { + fullySpecified: false + } + }, ], }, diff --git a/yarn.lock b/yarn.lock index a6b3c6796f..1d4c9bdfd0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6367,6 +6367,16 @@ listr2@^4.0.5: through "^2.3.8" wrap-ansi "^7.0.0" +lnmessage@^0.0.11: + version "0.0.11" + resolved "https://registry.yarnpkg.com/lnmessage/-/lnmessage-0.0.11.tgz#7e653fd52a8a6bde5f182a3193c137aa2832e701" + integrity sha512-LH90nvz8grM6sqy4/MDMVyNhvDDuDqdUgvil878IB4r3TqowEHnSw+dyN+7gReL1hudX5TfEGUYjbWiVQIcx/Q== + dependencies: + buffer "^6.0.3" + crypto-js "^4.1.1" + rxjs "^7.5.7" + secp256k1 "^4.0.3" + loader-runner@^4.2.0: version "4.2.0" resolved "https://registry.npmjs.org/loader-runner/-/loader-runner-4.2.0.tgz" @@ -8388,6 +8398,13 @@ rxjs@^7.5.5: dependencies: tslib "^2.1.0" +rxjs@^7.5.7: + version "7.5.7" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.7.tgz#2ec0d57fdc89ece220d2e702730ae8f1e49def39" + integrity sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA== + dependencies: + tslib "^2.1.0" + safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" @@ -8471,7 +8488,7 @@ schema-utils@^4.0.0: ajv-formats "^2.1.1" ajv-keywords "^5.0.0" -secp256k1@^4.0.2: +secp256k1@^4.0.2, secp256k1@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/secp256k1/-/secp256k1-4.0.3.tgz#c4559ecd1b8d3c1827ed2d1b94190d69ce267303" integrity sha512-NLZVf+ROMxwtEj3Xa562qgv2BK5e2WNmXPiOdVIPLgs6lyTzMvBq0aWTYMI5XCP9jZMVKOcqZLw/Wc4vDkuxhA==