diff --git a/package.json b/package.json index 5c0649a965..e7e052f9b3 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "lodash.merge": "^4.6.2", "lodash.pick": "^4.4.0", "lodash.snakecase": "^4.1.1", + "nostr-tools": "^1.17.0", "pubsub-js": "^1.9.4", "react": "^18.2.0", "react-confetti": "^6.1.0", diff --git a/src/app/router/connectorRoutes.tsx b/src/app/router/connectorRoutes.tsx index 4cd32fdb20..2ea902deb9 100644 --- a/src/app/router/connectorRoutes.tsx +++ b/src/app/router/connectorRoutes.tsx @@ -3,6 +3,7 @@ import ConnectBtcpay from "@screens/connectors/ConnectBtcpay"; import ConnectCitadel from "@screens/connectors/ConnectCitadel"; import ConnectEclair from "@screens/connectors/ConnectEclair"; import ConnectGaloy, { galoyUrls } from "@screens/connectors/ConnectGaloy"; +import ConnectLaWallet from "@screens/connectors/ConnectLaWallet"; import ConnectLnbits from "@screens/connectors/ConnectLnbits"; import ConnectLnc from "@screens/connectors/ConnectLnc"; import ConnectLnd from "@screens/connectors/ConnectLnd"; @@ -23,6 +24,7 @@ import core_ln from "/static/assets/icons/core_ln.svg"; import eclair from "/static/assets/icons/eclair.jpg"; import galoyBitcoinJungle from "/static/assets/icons/galoy_bitcoin_jungle.png"; import galoyBlink from "/static/assets/icons/galoy_blink.png"; +import lawallet from "/static/assets/icons/lawallet.png"; import lightning_node from "/static/assets/icons/lightning_node.png"; import lightning_terminal from "/static/assets/icons/lightning_terminal.png"; import lnbits from "/static/assets/icons/lnbits.png"; @@ -165,6 +167,12 @@ const connectorMap: { [key: string]: ConnectorRoute } = { title: i18n.t("translation:choose_connector.nwc.title"), logo: nwc, }, + lawallet: { + path: "lawallet", + element: , + title: i18n.t("translation:choose_connector.lawallet.title"), + logo: lawallet, + }, }; function getDistribution(key: string): ConnectorRoute { @@ -252,6 +260,13 @@ function getConnectorRoutes(): ConnectorRoute[] { connectorMap["voltage"], connectorMap[galoyPaths.blink], connectorMap[galoyPaths.bitcoinJungle], + getDistribution("citadel"), + getDistribution("umbrel"), + getDistribution("mynode"), + getDistribution("startos"), + getDistribution("raspiblitz"), + connectorMap["nwc"], + connectorMap["lawallet"], connectorMap["lnd-hub-go"], connectorMap["eclair"], ]; diff --git a/src/app/screens/connectors/ConnectLaWallet/LaWalletToast.tsx b/src/app/screens/connectors/ConnectLaWallet/LaWalletToast.tsx new file mode 100644 index 0000000000..2d12977171 --- /dev/null +++ b/src/app/screens/connectors/ConnectLaWallet/LaWalletToast.tsx @@ -0,0 +1,44 @@ +import { Trans, useTranslation } from "react-i18next"; + +export default function LaWalletToast({ domain }: { domain: string }) { + const { t } = useTranslation("translation", { + keyPrefix: "choose_connector.lawallet.errors.toast", + }); + + return ( + <> +

+ {t("title")} ( + {t("message", { domain })}) +

+ +

{t("verify")}

+ + + ); +} diff --git a/src/app/screens/connectors/ConnectLaWallet/index.tsx b/src/app/screens/connectors/ConnectLaWallet/index.tsx new file mode 100644 index 0000000000..36ed8f6a35 --- /dev/null +++ b/src/app/screens/connectors/ConnectLaWallet/index.tsx @@ -0,0 +1,206 @@ +import ConnectorForm from "@components/ConnectorForm"; +import TextField from "@components/form/TextField"; +import ConnectionErrorToast from "@components/toasts/ConnectionErrorToast"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import toast from "~/app/components/Toast"; +import msg from "~/common/lib/msg"; + +import Button from "~/app/components/Button"; +import PasswordViewAdornment from "~/app/components/PasswordViewAdornment"; +import LaWalletToast from "~/app/screens/connectors/ConnectLaWallet/LaWalletToast"; +import LaWallet, { + HttpError, +} from "~/extension/background-script/connectors/lawallet"; +import Nostr from "~/extension/background-script/nostr"; +import { ConnectorType } from "~/types"; +import logo from "/static/assets/icons/lawallet.png"; + +const initialFormData = { + private_key: "", + api_endpoint: "https://api.lawallet.ar", + identity_endpoint: "https://lawallet.ar", + ledger_public_key: + "bd9b0b60d5cd2a9df282fc504e88334995e6fac8b148fa89e0f8c09e2a570a84", + urlx_public_key: + "e17feb5f2cf83546bcf7fd9c8237b05275be958bd521543c2285ffc6c2d654b3", + relay_url: "wss://relay.lawallet.ar", +}; + +export default function ConnectLaWallet() { + const navigate = useNavigate(); + const { t } = useTranslation("translation", { + keyPrefix: "choose_connector.lawallet", + }); + const [passwordViewVisible, setPasswordViewVisible] = useState(false); + const { t: tCommon } = useTranslation("common"); + const [formData, setFormData] = useState(initialFormData); + const [loading, setLoading] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); + + function getConnectorType(): ConnectorType { + return "lawallet"; + } + + function handleChange(event: React.ChangeEvent) { + setFormData({ + ...formData, + [event.target.name]: event.target.value.trim(), + }); + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setLoading(true); + const { + private_key, + api_endpoint, + identity_endpoint, + ledger_public_key, + urlx_public_key, + relay_url, + } = formData; + + const publicKey = new Nostr(private_key).getPublicKey(); + const domain = identity_endpoint.replace(/https?:\/\//, ""); + + let username; + try { + const response = await LaWallet.request<{ username: string }>( + identity_endpoint, + "GET", + `/api/pubkey/${publicKey}`, + undefined + ); + username = response.username; + } catch (e) { + if (e instanceof HttpError && e.status === 404) { + toast.error(, { + position: "top-center", + }); + } else { + toast.error(); + } + + setLoading(false); + return; + } + + const account = { + name: `${username}@${domain}`, + config: { + privateKey: private_key, + apiEndpoint: api_endpoint, + identityEndpoint: identity_endpoint, + ledgerPublicKey: ledger_public_key, + urlxPublicKey: urlx_public_key, + relayUrl: relay_url, + }, + connector: getConnectorType(), + }; + + await msg.request("validateAccount", account); + + try { + const addResult = await msg.request("addAccount", account); + if (addResult.accountId) { + await msg.request("selectAccount", { + id: addResult.accountId, + }); + navigate("/test-connection"); + } + } catch (e) { + console.error(e); + let message = ""; + if (e instanceof Error) { + message += `${e.message}`; + } + toast.error(); + } + setLoading(false); + } + + return ( + +
+ { + setPasswordViewVisible(passwordView); + }} + /> + } + /> + + + +
+ {showAdvanced && ( +
+ + + + + +
+ )} +
+ ); +} diff --git a/src/extension/background-script/connectors/index.ts b/src/extension/background-script/connectors/index.ts index 95bbaef680..0a682d66b6 100644 --- a/src/extension/background-script/connectors/index.ts +++ b/src/extension/background-script/connectors/index.ts @@ -4,6 +4,7 @@ import Commando from "./commando"; import Eclair from "./eclair"; import Galoy from "./galoy"; import Kollider from "./kollider"; +import LaWallet from "./lawallet"; import LnBits from "./lnbits"; import Lnc from "./lnc"; import Lnd from "./lnd"; @@ -38,6 +39,7 @@ const connectors = { commando: Commando, alby: Alby, nwc: NWC, + lawallet: LaWallet, }; export default connectors; diff --git a/src/extension/background-script/connectors/lawallet.ts b/src/extension/background-script/connectors/lawallet.ts new file mode 100644 index 0000000000..999059686a --- /dev/null +++ b/src/extension/background-script/connectors/lawallet.ts @@ -0,0 +1,531 @@ +import { schnorr } from "@noble/curves/secp256k1"; +import * as secp256k1 from "@noble/secp256k1"; +import type { ResponseType } from "axios"; +import { Method } from "axios"; +import lightningPayReq from "bolt11-signet"; +import Hex from "crypto-js/enc-hex"; +import sha256 from "crypto-js/sha256"; +import { nip04, relayInit, type Relay } from "nostr-tools"; +import { Event, EventKind } from "~/extension/providers/nostr/types"; +import { Account } from "~/types"; + +import toast from "~/app/components/Toast"; +import { getEventHash } from "~/extension/background-script/actions/nostr/helpers"; +import Nostr from "~/extension/background-script/nostr"; +import Connector, { + CheckPaymentArgs, + CheckPaymentResponse, + ConnectPeerResponse, + ConnectorTransaction, + GetBalanceResponse, + GetInfoResponse, + GetTransactionsResponse, + KeysendArgs, + MakeInvoiceArgs, + MakeInvoiceResponse, + SendPaymentArgs, + SendPaymentResponse, + SignMessageArgs, + SignMessageResponse, +} from "./connector.interface"; + +interface Config { + privateKey: string; + apiEndpoint: string; + identityEndpoint: string; + ledgerPublicKey: string; + urlxPublicKey: string; + relayList: string[]; + relayUrl: string; +} + +export class HttpError extends Error { + status: number; + error: Error | undefined; + + constructor(status: number, message: string, error?: Error) { + super(message); + this.status = status; + this.error = error; + } +} + +export default class LaWallet implements Connector { + account: Account; + config: Config; + relay: Relay; + public_key: string; + access_token?: string; + access_token_created?: number; + refresh_token?: string; + refresh_token_created?: number; + noRetry?: boolean; + + invoices_paid: InvoiceCache = {}; + last_invoice_check: number = 0; + + constructor(account: Account, config: Config) { + this.account = account; + this.config = config; + this.public_key = new Nostr(config.privateKey).getPublicKey(); + this.relay = relayInit(config.relayUrl); + } + + async init() { + return Promise.resolve(); + } + + unload() { + this.relay.close(); + return Promise.resolve(); + } + + get supportedMethods() { + return [ + "getInfo", + "makeInvoice", + "sendPayment", + "signMessage", + "getBalance", + "getTransactions", + ]; + } + + async connectPeer(): Promise { + console.error( + `${this.constructor.name} does not implement the getInvoices call` + ); + throw new Error("Not yet supported with the currently used account."); + } + + async getTransactions(): Promise { + const _transactions: Event[] = await LaWallet.request( + this.config.apiEndpoint, + "POST", + "/nostr/fetch", + { + authors: [this.config.ledgerPublicKey, this.config.urlxPublicKey], + kinds: [1112], + since: 0, + "#t": ["internal-transaction-ok", "inbound-transaction-start"], + "#p": [this.public_key], + } + ); + + const transactions = _transactions.map((event) => { + return { + ...event, + kind: event.kind as EventKind, + }; + }) as Event[]; + + const parsedTransactions: ConnectorTransaction[] = await Promise.all( + transactions.map( + parseTransaction.bind(this, this.public_key, this.config.privateKey) + ) + ); + + return { + data: { + transactions: parsedTransactions.sort( + (a, b) => b.settleDate - a.settleDate + ), + }, + }; + } + + async getInfo(): Promise< + GetInfoResponse<{ + alias: string; + pubkey: string; + lightning_address: string; + }> + > { + const { username, nodeAlias } = await LaWallet.request<{ + username: string; + nodeAlias?: string; + }>( + this.config.identityEndpoint, + "GET", + `/api/pubkey/${this.public_key}`, + undefined + ); + const domain = this.config.identityEndpoint.replace("https://", ""); + return { + data: { + alias: nodeAlias || domain, + pubkey: this.public_key, + lightning_address: `${username}@${domain}`, + }, + }; + } + + async getBalance(): Promise { + const filter = { + authors: [this.config.ledgerPublicKey], + kinds: [31111], + "#d": [`balance:BTC:${this.public_key}`], + }; + + const events: Event[] = await LaWallet.request( + this.config.apiEndpoint, + "POST", + "/nostr/fetch", + filter + ); + + const balanceEvent = events[0]; + + return { + data: { + balance: balanceEvent + ? parseInt( + balanceEvent?.tags.find( + (tag) => tag[0] === "amount" + )?.[1] as string + ) / 1000 + : 0, + }, + }; + } + + async sendPayment(args: SendPaymentArgs): Promise { + const paymentRequestDetails = lightningPayReq.decode(args.paymentRequest); + const unsignedEvent: Event = { + kind: 1112 as EventKind, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["t", "internal-transaction-start"], + ["p", this.config.ledgerPublicKey], + ["p", this.config.urlxPublicKey], + ["bolt11", args.paymentRequest], + ], + content: JSON.stringify({ + tokens: { BTC: paymentRequestDetails.millisatoshis?.toString() }, + }), + }; + + const event: Event = finishEvent(unsignedEvent, this.config.privateKey); + + try { + await LaWallet.request( + this.config.apiEndpoint, + "POST", + "/nostr/publish", + event, + "blob" + ); + this.relay.connect(); + return this.getPaymentStatus(event); + } catch (e) { + console.error(e); + if (e instanceof Error) toast.error(`${e.message}`); + throw e; + } + } + + async keysend(args: KeysendArgs): Promise { + console.error( + `${this.constructor.name} does not implement the keysend call` + ); + throw new Error("Keysend not yet supported."); + } + + private onZapReceipt(event: Event) { + const pr = event.tags.find((tag) => tag[0] === "bolt11")?.[1] as string; + const paymentHash = lightningPayReq.decode(pr).tagsObject.payment_hash!; + this.invoices_paid[paymentHash] = true; + } + + private async getPaymentStatus(event: Event): Promise { + const paymentRequestDetails = lightningPayReq.decode( + event.tags.find((tag) => tag[0] === "bolt11")?.[1] as string + ); + const amountInSats = paymentRequestDetails.satoshis || 0; + const payment_route = { total_amt: amountInSats, total_fees: 0 }; + + await this.relay.connect(); + return new Promise((resolve, reject) => { + const sub = this.relay.sub([ + { + authors: [this.config.ledgerPublicKey, this.config.urlxPublicKey], + "#e": [event.id!], + "#t": [ + "internal-transaction-error", + "internal-transaction-ok", + "outbound-transaction-start", + ], + }, + ]); + + sub.on("event", async (event) => { + const tag = event.tags.find((tag) => tag[0] === "t")![1]; + const content = JSON.parse(event.content); + switch (tag) { + case "internal-transaction-ok": // Refund + if (event.tags[1][1] === this.public_key && !!content.memo) { + return reject(new Error(content.memo)); + } + break; + case "internal-transaction-error": // No funds or ledger error + return reject(new Error(content.messages[0])); + case "outbound-transaction-start": // Payment done + return resolve({ + data: { + preimage: await extractPreimage(event, this.config.privateKey), + paymentHash: paymentRequestDetails.tagsObject.payment_hash!, + route: payment_route, + }, + }); + } + }); + }); + } + + async checkPayment(args: CheckPaymentArgs): Promise { + const zapReceipts = await this.getZapReceipts(10, this.last_invoice_check); + zapReceipts.forEach(this.onZapReceipt.bind(this)); + return { + data: { + paid: !!this.invoices_paid[args.paymentHash], + }, + }; + } + + signMessage(args: SignMessageArgs): Promise { + if (!this.config.apiEndpoint || !this.config.privateKey) { + return Promise.reject(new Error("Missing config")); + } + if (!args.message) { + return Promise.reject(new Error("Invalid message")); + } + + return Promise.resolve({ + data: { + message: args.message, + signature: schnorr + .sign(sha256(args.message).toString(Hex), this.config.privateKey) + .toString(), + }, + }); + } + + async makeInvoice(args: MakeInvoiceArgs): Promise { + const unsignedZapEvent = makeZapRequest({ + profile: this.public_key, + event: null, + amount: (args.amount as number) * 1000, + comment: args.memo, + relays: this.config.relayList, + }); + + const zapEvent: Event = finishEvent( + unsignedZapEvent, + this.config.privateKey + ); + + const params = { + amount: String((args.amount as number) * 1000), + comment: args.memo, + nostr: JSON.stringify(zapEvent), + lnurl: this.public_key, + }; + + const url = `/lnurlp/${this.public_key}/callback?${new URLSearchParams( + params + )}`; + + const data = await LaWallet.request<{ + pr: string; + }>(this.config.apiEndpoint, "GET", url); + + const paymentRequestDetails = lightningPayReq.decode(data.pr); + + this.last_invoice_check = Math.floor(Date.now() / 1000); + + return { + data: { + paymentRequest: data.pr, + rHash: paymentRequestDetails.tagsObject.payment_hash!, + }, + }; + } + + private async getZapReceipts( + limit: number = 10, + since: number = 0 + ): Promise { + const filter = { + authors: [this.config.urlxPublicKey], + kinds: [9735], + since, + limit, + "#p": [this.public_key], + }; + + const zapEvents: Event[] = await LaWallet.request( + this.config.apiEndpoint, + "POST", + "/nostr/fetch", + filter + ); + + return zapEvents; + } + + // Static Methods + + /** + * + * @param url The URL to fetch data from. + * @param method HTTP Method + * @param path API path + * @param args POST arguments + * @param responseType + * @returns + * @throws {HttpError} When the response has an HTTP error status. + */ + static async request( + url: string, + method: Method, + path: string, + args: Record = {}, + responseType: ResponseType = "json" + ): Promise { + const headers = new Headers(); + headers.append("Accept", "application/json"); + headers.append("Content-Type", "application/json"); + + let body = null; + let res = null; + + if (method !== "GET") { + body = JSON.stringify(args); + } + + try { + res = await fetch(`${url}${path}`, { + headers, + method, + body, + }); + } catch (e: unknown) { + throw new HttpError(0, "Network error", e as Error); + } + + if (!res.ok) { + throw new HttpError(res.status, await res.text()); + } + return await (responseType === "json" ? res.json() : res.text()); + } +} + +interface TransactionEventContent { + tokens: { BTC: number }; + memo?: string; +} + +interface InvoiceCache { + [paymentHash: string]: boolean; // paid +} + +/** Utils Functions **/ + +async function extractPreimage( + event: Event, + privateKey: string +): Promise { + try { + const encrypted = event.tags.find( + (tag) => tag[0] === "preimage" + )?.[1] as string; + + const messageKeyHex: string = await nip04.decrypt( + privateKey, + event.pubkey as string, + encrypted + ); + + return messageKeyHex; + } catch (e) { + return ""; + } +} + +export async function parseTransaction( + userPubkey: string, + privateKey: string, + event: Event +): Promise { + const content = JSON.parse(event.content) as TransactionEventContent; + // Get bolt11 tag + const bolt11 = event.tags.find((tag) => tag[0] === "bolt11")?.[1] as string; + + let paymentHash = event.id; + let memo = content.memo || ""; + + // Check if the event is a payment request + if (bolt11) { + const paymentRequestDetails = lightningPayReq.decode(bolt11); + paymentHash = paymentRequestDetails.tagsObject.payment_hash!; + memo = paymentRequestDetails.tagsObject.description || memo; + } + + return { + id: event.id!, + preimage: await extractPreimage(event, privateKey), + settled: true, + settleDate: event.created_at * 1000, + totalAmount: content.tokens.BTC / 1000, + type: event.tags[1][1] === userPubkey ? "received" : "sent", + custom_records: {}, + memo: memo, + payment_hash: paymentHash, + }; +} + +export function makeZapRequest({ + profile, + event, + amount, + relays = ["wss://relay.lawallet.ar"], + comment = "", +}: { + profile: string; + event: string | null; + amount: number; + comment: string; + relays: string[]; +}): Event { + if (!amount) throw new Error("amount not given"); + if (!profile) throw new Error("profile not given"); + + const zr: Event = { + kind: 9734, + created_at: Math.round(Date.now() / 1000), + content: comment, + tags: [ + ["p", profile], + ["amount", String(amount)], + ["relays", ...relays], + ], + }; + + if (event) { + zr.tags.push(["e", event]); + } + + return zr; +} + +export function finishEvent(event: Event, privateKey: string): Event { + event.pubkey = new Nostr(privateKey).getPublicKey(); + event.id = getEventHash(event); + event.sig = signEvent(event, privateKey); + return event; +} + +export function signEvent(event: Event, key: string) { + const signedEvent = schnorr.sign(getEventHash(event), key); + return secp256k1.etc.bytesToHex(signedEvent); +} diff --git a/src/extension/providers/nostr/types.ts b/src/extension/providers/nostr/types.ts index 85e8f2f357..1ad1bf2a35 100644 --- a/src/extension/providers/nostr/types.ts +++ b/src/extension/providers/nostr/types.ts @@ -15,4 +15,6 @@ export enum EventKind { Contacts = 3, DM = 4, Deleted = 5, + ZapRequest = 9734, + ZapReceipt = 9735, } diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 6f2d44102a..02dfef3ae2 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -268,6 +268,30 @@ "connection_failed": "Connection failed. Is the BTCPay connection URL correct and accessible?" } }, + "lawallet": { + "title": "LaWallet.io", + "page": { + "title": "Connect to LaWallet", + "instructions": "Set your LaWallet credentials below" + }, + "private_key": "Private Key", + "api_endpoint": "API Endpoint", + "identity_endpoint": "Identity Endpoint", + "ledger_public_key": "Ledger Public key", + "urlx_public_key": "URLx Public key", + "relay_url": "Relay URL", + "errors": { + "toast": { + "title": "Identity not found", + "message": "You have no lightning address in the identity \"{{domain}}\"", + "verify": "Verify the following", + "match": "Identity Endpoint must match your Lightning Domain", + "walias": "Is your lightning address USERNAME<0>@{{domain}} or its using another domain?", + "endpoint": "The correct endpoint is built from https://app.<0>{{domain}}", + "http": "The identity provider is using the same protocol (<0>HTTP or <0>HTTPS)" + } + } + }, "commando": { "title": "Core Lightning", "page": { diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 81038166c7..9b329004b7 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -178,6 +178,30 @@ "connection_failed": "La conexión falló. ¿Es correcto el URI de LNDHub?" } }, + "lawallet": { + "title": "LaWallet.io", + "page": { + "title": "Conectar a LaWallet", + "instructions": "Ingresá los datos de LaWallet.io a continuación." + }, + "private_key": "Llave privada", + "api_endpoint": "API Endpoint", + "identity_endpoint": "Identity Endpoint", + "ledger_public_key": "Ledger Public key", + "urlx_public_key": "URLx Public key", + "relay_url": "Relay URL", + "errors": { + "toast": { + "title": "No se encontró la identidad", + "message": "No hay una lightning address asignada a \"{{domain}}\"", + "verify": "Verificá lo siguiente", + "match": "El Identity Endpoint debe coincidir con el Lightning Domain", + "walias": "Tu lightning address es USERNAME<0>@{{domain}} o está utilizando otro dominio?", + "endpoint": "El Identity endpoint se deriva de esta url https://app.<0>{{domain}} de App", + "http": "El Identity endpoint utiliza el mismo protocolo (<0>HTTP or <0>HTTPS)" + } + } + }, "commando": { "pubkey": { "label": "Clave Pública" diff --git a/static/assets/icons/lawallet.png b/static/assets/icons/lawallet.png new file mode 100644 index 0000000000..c19899c053 Binary files /dev/null and b/static/assets/icons/lawallet.png differ