diff --git a/.gitignore b/.gitignore index 4913ce5e7..3a7eb7880 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ node_modules/ .DS_Store *.pem /*.sql +lnbits/ # debug npm-debug.log* diff --git a/components/banners.js b/components/banners.js index 50dee8495..a6956e8ae 100644 --- a/components/banners.js +++ b/components/banners.js @@ -88,3 +88,20 @@ export function WalletLimitBanner () { ) } + +export function WalletSecurityBanner () { + return ( + + + Wallet Security Disclaimer + +

+ Your wallet's credentials are stored in the browser and never go to the server.
+ However, you should definitely set a budget in your wallet. +

+

+ Also, for the time being, you will have to reenter your credentials on other devices. +

+
+ ) +} diff --git a/components/form.js b/components/form.js index 798dba216..9a1d1f0b1 100644 --- a/components/form.js +++ b/components/form.js @@ -961,3 +961,22 @@ export function DatePicker ({ fromName, toName, noForm, onChange, when, from, to /> ) } + +function Client (Component) { + return ({ initialValue, ...props }) => { + // This component can be used for Formik fields + // where the initial value is not available on first render. + // Example: value is stored in localStorage which is fetched + // after first render using an useEffect hook. + const [,, helpers] = useField(props) + + useEffect(() => { + helpers.setValue(initialValue) + }, [initialValue]) + + return + } +} + +export const ClientInput = Client(Input) +export const ClientCheckbox = Client(Checkbox) diff --git a/components/invoice.js b/components/invoice.js index 034955f43..a356e4c3b 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect } from 'react' -import { useMutation, useQuery } from '@apollo/client' +import { useApolloClient, useMutation, useQuery } from '@apollo/client' import { Button } from 'react-bootstrap' import { gql } from 'graphql-tag' import { numWithUnits } from '../lib/format' @@ -12,13 +12,17 @@ import { useShowModal } from './modal' import Countdown from './countdown' import PayerData from './payer-data' import Bolt11Info from './bolt11-info' +import { useWebLN } from './webln' -export function Invoice ({ invoice, modal, onPayment, info, successVerb }) { +export function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn }) { const [expired, setExpired] = useState(new Date(invoice.expiredAt) <= new Date()) + // if webLn was not passed, use true by default + if (webLn === undefined) webLn = true + let variant = 'default' let status = 'waiting for you' - let webLn = true + if (invoice.cancelled) { variant = 'failed' status = 'cancelled' @@ -118,7 +122,7 @@ const JITInvoice = ({ invoice: { id, hash, hmac, expiresAt }, onPayment, onCance return ( <> - + {retry ? ( <> @@ -161,6 +165,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { mutation createInvoice($amount: Int!) { createInvoice(amount: $amount, hodlInvoice: true, expireSecs: 180) { id + bolt11 hash hmac expiresAt @@ -175,8 +180,13 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { `) const showModal = useShowModal() + const provider = useWebLN() + const client = useApolloClient() + const pollInvoice = (id) => client.query({ query: INVOICE, fetchPolicy: 'no-cache', variables: { id } }) - const onSubmitWrapper = useCallback(async ({ cost, ...formValues }, ...submitArgs) => { + const onSubmitWrapper = useCallback(async ( + { cost, ...formValues }, + { variables, optimisticResponse, update, ...submitArgs }) => { // some actions require a session if (!me && options.requireSession) { throw new Error('you must be logged in') @@ -189,7 +199,9 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { // attempt action for the first time if (!cost || (me && !options.forceInvoice)) { try { - return await onSubmit(formValues, ...submitArgs) + const insufficientFunds = me?.privates.sats < cost + return await onSubmit(formValues, + { ...submitArgs, variables, optimisticsResponse: insufficientFunds ? null : optimisticResponse }) } catch (error) { if (!payOrLoginError(error) || !cost) { // can't handle error here - bail @@ -205,27 +217,52 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { } const inv = data.createInvoice + // If this is a zap, we need to manually be optimistic to have a consistent + // UX across custodial and WebLN zaps since WebLN zaps don't call GraphQL + // mutations which implement optimistic responses natively. + // Therefore, we check if this is a zap and then wrap the WebLN payment logic + // with manual cache update calls. + const itemId = optimisticResponse?.act?.id + const isZap = !!itemId + let _update + if (isZap && update) { + _update = () => { + const fragment = { + id: `Item:${itemId}`, + fragment: gql` + fragment ItemMeSats on Item { + sats + meSats + } + ` + } + const item = client.cache.readFragment(fragment) + update(client.cache, { data: optimisticResponse }) + // undo function + return () => client.cache.writeFragment({ ...fragment, data: item }) + } + } + // wait until invoice is paid or modal is closed - let modalClose - await new Promise((resolve, reject) => { - showModal(onClose => { - modalClose = onClose - return ( - - ) - }, { keepOpen: true, onClose: reject }) + const { modalOnClose, webLn, gqlCacheUpdateUndo } = await waitForPayment({ + invoice: inv, + showModal, + provider, + pollInvoice, + gqlCacheUpdate: _update }) - const retry = () => onSubmit({ hash: inv.hash, hmac: inv.hmac, ...formValues }, ...submitArgs) + const retry = () => onSubmit( + { hash: inv.hash, hmac: inv.hmac, ...formValues }, + // unset update function since we already ran an cache update if we paid using WebLN + { ...submitArgs, variables, update: webLn ? null : undefined }) // first retry try { const ret = await retry() - modalClose() + modalOnClose?.() return ret } catch (error) { + gqlCacheUpdateUndo?.() console.error('retry error:', error) } @@ -245,16 +282,91 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { }} onRetry={async () => { resolve(await retry()) + onClose() }} /> ) }, { keepOpen: true, onClose: cancelAndReject }) }) - }, [onSubmit, createInvoice, !!me]) + }, [onSubmit, provider, createInvoice, !!me]) return onSubmitWrapper } +const INVOICE_CANCELED_ERROR = 'invoice was canceled' +const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, gqlCacheUpdate }) => { + if (provider.enabled) { + try { + return await waitForWebLNPayment({ provider, invoice, pollInvoice, gqlCacheUpdate }) + } catch (err) { + const INVOICE_CANCELED_ERROR = 'invoice was canceled' + // check for errors which mean that QR code will also fail + if (err.message === INVOICE_CANCELED_ERROR) { + throw err + } + } + } + + // QR code as fallback + return await new Promise((resolve, reject) => { + showModal(onClose => { + return ( + resolve({ modalOnClose: onClose })} + /> + ) + }, { keepOpen: true, onClose: reject }) + }) +} + +const waitForWebLNPayment = async ({ provider, invoice, pollInvoice, gqlCacheUpdate }) => { + let undoUpdate + try { + // try WebLN provider first + return await new Promise((resolve, reject) => { + // be optimistic and pretend zap was already successful for consistent zapping UX + undoUpdate = gqlCacheUpdate?.() + // can't use await here since we might be paying HODL invoices + // and sendPaymentAsync is not supported yet. + // see https://www.webln.guide/building-lightning-apps/webln-reference/webln.sendpaymentasync + provider.sendPayment(invoice) + // WebLN payment will never resolve here for HODL invoices + // since they only get resolved after settlement which can't happen here + .then(() => resolve({ webLn: true, gqlCacheUpdateUndo: undoUpdate })) + .catch(err => { + clearInterval(interval) + reject(err) + }) + const interval = setInterval(async () => { + try { + const { data, error } = await pollInvoice(invoice.id) + if (error) { + clearInterval(interval) + return reject(error) + } + const { invoice: inv } = data + if (inv.isHeld && inv.satsReceived) { + clearInterval(interval) + resolve({ webLn: true, gqlCacheUpdateUndo: undoUpdate }) + } + if (inv.cancelled) { + clearInterval(interval) + reject(new Error(INVOICE_CANCELED_ERROR)) + } + } catch (err) { + clearInterval(interval) + reject(err) + } + }, 1000) + }) + } catch (err) { + undoUpdate?.() + console.error('WebLN payment failed:', err) + throw err + } +} + export const useInvoiceModal = (onPayment, deps) => { const onPaymentMemo = useCallback(onPayment, deps) return useInvoiceable(onPaymentMemo, { replaceModal: true }) diff --git a/components/item-act.js b/components/item-act.js index 6a9f8365d..574d18b17 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -234,9 +234,9 @@ export function useZap () { const strike = useLightning() const [act] = useAct() - const showInvoiceModal = useInvoiceModal( - async ({ hash, hmac }, { variables }) => { - await act({ variables: { ...variables, hash, hmac } }) + const invoiceableAct = useInvoiceModal( + async ({ hash, hmac }, { variables, ...apolloArgs }) => { + await act({ variables: { ...variables, hash, hmac }, ...apolloArgs }) strike() }, [act, strike]) @@ -254,22 +254,22 @@ export function useZap () { } const variables = { id: item.id, sats, act: 'TIP' } + const insufficientFunds = me?.privates.sats < sats + const optimisticResponse = { act: { path: item.path, ...variables } } try { - await zap({ - variables, - optimisticResponse: { - act: { - path: item.path, - ...variables - } - } - }) + if (!insufficientFunds) strike() + await zap({ variables, optimisticResponse: insufficientFunds ? null : optimisticResponse }) } catch (error) { if (payOrLoginError(error)) { // call non-idempotent version const amount = sats - meSats + optimisticResponse.act.amount = amount try { - await showInvoiceModal({ amount }, { variables: { ...variables, sats: amount } }) + await invoiceableAct({ amount }, { + variables: { ...variables, sats: amount }, + optimisticResponse, + update + }) } catch (error) {} return } diff --git a/components/qr.js b/components/qr.js index 5a7f866a9..422b9c74f 100644 --- a/components/qr.js +++ b/components/qr.js @@ -1,20 +1,24 @@ import QRCode from 'qrcode.react' import { CopyInput, InputSkeleton } from './form' import InvoiceStatus from './invoice-status' -import { requestProvider } from 'webln' import { useEffect } from 'react' +import { useWebLN } from './webln' +import { useToast } from './toast' export default function Qr ({ asIs, value, webLn, statusVariant, description, status }) { const qrValue = asIs ? value : 'lightning:' + value.toUpperCase() + const provider = useWebLN() + const toaster = useToast() + useEffect(() => { async function effect () { if (webLn) { try { - const provider = await requestProvider() await provider.sendPayment(value) } catch (e) { - console.log(e.message) + console.log(e?.message) + toaster.danger(`${provider.name}: ${e?.message}`) } } } diff --git a/components/upvote.js b/components/upvote.js index 49de82110..832e00f98 100644 --- a/components/upvote.js +++ b/components/upvote.js @@ -10,7 +10,6 @@ import LongPressable from 'react-longpressable' import Overlay from 'react-bootstrap/Overlay' import Popover from 'react-bootstrap/Popover' import { useShowModal } from './modal' -import { useLightning } from './lightning' import { numWithUnits } from '../lib/format' import { Dropdown } from 'react-bootstrap' @@ -111,7 +110,6 @@ export default function UpVote ({ item, className }) { const [act] = useAct() const zap = useZap() - const strike = useLightning() const disabled = useMemo(() => item?.mine || item?.meForward || item?.deletedAt, [item?.mine, item?.meForward, item?.deletedAt]) @@ -168,8 +166,6 @@ export default function UpVote ({ item, className }) { setTipShow(true) } - strike() - zap({ item, me }) } : () => showModal(onClose => ) diff --git a/components/wallet-card.js b/components/wallet-card.js index 50ce06df4..dcfa0385e 100644 --- a/components/wallet-card.js +++ b/components/wallet-card.js @@ -7,9 +7,10 @@ import CancelButton from './cancel-button' import { SubmitButton } from './form' export function WalletCard ({ title, badges, provider, enabled }) { + const isConfigured = enabled === true || enabled === false return ( -
+
{title} @@ -23,7 +24,7 @@ export function WalletCard ({ title, badges, provider, enabled }) { {provider && - {enabled + {isConfigured ? <>configure : <>attach} @@ -40,7 +41,7 @@ export function WalletButtonBar ({ return (
- {enabled && + {enabled !== undefined && } {children}
diff --git a/components/webln/index.js b/components/webln/index.js new file mode 100644 index 000000000..a4a96fc38 --- /dev/null +++ b/components/webln/index.js @@ -0,0 +1,142 @@ +import { createContext, useContext, useEffect, useState } from 'react' +import { LNbitsProvider, useLNbits } from './lnbits' +import { NWCProvider, useNWC } from './nwc' +import { useToast } from '../toast' +import { gql, useMutation } from '@apollo/client' + +const WebLNContext = createContext({}) +const storageKey = 'webln:providers' + +const paymentMethodHook = (methods, { name, enabled }) => { + let newMethods + if (enabled) { + newMethods = methods.includes(name) ? methods : [...methods, name] + } else { + newMethods = methods.filter(m => m !== name) + } + savePaymentMethods(newMethods) + return newMethods +} + +const savePaymentMethods = (methods) => { + window.localStorage.setItem(storageKey, JSON.stringify(methods)) +} + +function RawWebLNProvider ({ children }) { + // LNbits should only be used during development + // since it gives full wallet access on XSS + const lnbits = useLNbits() + const nwc = useNWC() + const providers = [lnbits, nwc] + + // TODO: Order of payment methods depends on user preference. + // Payment method at index 0 should be default, + // if that one fails we try the remaining ones in order as fallbacks. + // We should be able to implement this via dragging of cards. + // This list should then match the order in which the (payment) cards are rendered. + // eslint-disable-next-line no-unused-vars + const [paymentMethods, setPaymentMethods] = useState([]) + const loadPaymentMethods = () => { + const methods = window.localStorage.getItem(storageKey) + if (!methods) return + setPaymentMethods(JSON.parse(methods)) + } + useEffect(loadPaymentMethods, []) + + const toaster = useToast() + const [cancelInvoice] = useMutation(gql` + mutation cancelInvoice($hash: String!, $hmac: String!) { + cancelInvoice(hash: $hash, hmac: $hmac) { + id + } + } + `) + + useEffect(() => { + setPaymentMethods(methods => paymentMethodHook(methods, nwc)) + if (!nwc.enabled) nwc.setIsDefault(false) + }, [nwc.enabled]) + + useEffect(() => { + setPaymentMethods(methods => paymentMethodHook(methods, lnbits)) + if (!lnbits.enabled) lnbits.setIsDefault(false) + }, [lnbits.enabled]) + + const setDefaultPaymentMethod = (provider) => { + for (const p of providers) { + if (p.name !== provider.name) { + p.setIsDefault(false) + } + } + } + + useEffect(() => { + if (nwc.isDefault) setDefaultPaymentMethod(nwc) + }, [nwc.isDefault]) + + useEffect(() => { + if (lnbits.isDefault) setDefaultPaymentMethod(lnbits) + }, [lnbits.isDefault]) + + // TODO: implement numeric provider priority using paymentMethods list + // when we have more than two providers for sending + let provider = providers.filter(p => p.enabled && p.isDefault)[0] + if (!provider && providers.length > 0) { + // if no provider is the default, pick the first one and use that one as the default + provider = providers.filter(p => p.enabled)[0] + if (provider) { + provider.setIsDefault(true) + } + } + + const sendPaymentWithToast = function ({ bolt11, hash, hmac }) { + let canceled = false + let removeToast = toaster.warning('payment pending', { + autohide: false, + onCancel: async () => { + try { + await cancelInvoice({ variables: { hash, hmac } }) + canceled = true + toaster.warning('payment canceled') + removeToast = undefined + } catch (err) { + toaster.danger('failed to cancel payment') + } + } + }) + return provider.sendPayment(bolt11) + .then(({ preimage }) => { + removeToast?.() + toaster.success('payment successful') + return { preimage } + }).catch((err) => { + if (canceled) return + removeToast?.() + const reason = err?.message?.toString().toLowerCase() || 'unknown reason' + toaster.danger(`payment failed: ${reason}`) + throw err + }) + } + + return ( + + {children} + + ) +} + +export function WebLNProvider ({ children }) { + return ( + + + + {children} + + + + ) +} + +export function useWebLN () { + return useContext(WebLNContext) +} diff --git a/components/webln/lnbits.js b/components/webln/lnbits.js new file mode 100644 index 000000000..5bf5cf207 --- /dev/null +++ b/components/webln/lnbits.js @@ -0,0 +1,166 @@ +import { createContext, useCallback, useContext, useEffect, useState } from 'react' + +// Reference: https://github.com/getAlby/bitcoin-connect/blob/v3.2.0-alpha/src/connectors/LnbitsConnector.ts + +const LNbitsContext = createContext() + +const getWallet = async (baseUrl, adminKey) => { + const url = baseUrl.replace(/\/+$/, '') + const path = '/api/v1/wallet' + + const headers = new Headers() + headers.append('Accept', 'application/json') + headers.append('Content-Type', 'application/json') + headers.append('X-Api-Key', adminKey) + + const res = await fetch(url + path, { method: 'GET', headers }) + if (!res.ok) { + const errBody = await res.json() + throw new Error(errBody.detail) + } + const wallet = await res.json() + return wallet +} + +const postPayment = async (baseUrl, adminKey, bolt11) => { + const url = baseUrl.replace(/\/+$/, '') + const path = '/api/v1/payments' + + const headers = new Headers() + headers.append('Accept', 'application/json') + headers.append('Content-Type', 'application/json') + headers.append('X-Api-Key', adminKey) + + const body = JSON.stringify({ bolt11, out: true }) + + const res = await fetch(url + path, { method: 'POST', headers, body }) + if (!res.ok) { + const errBody = await res.json() + throw new Error(errBody.detail) + } + const payment = await res.json() + return payment +} + +const getPayment = async (baseUrl, adminKey, paymentHash) => { + const url = baseUrl.replace(/\/+$/, '') + const path = `/api/v1/payments/${paymentHash}` + + const headers = new Headers() + headers.append('Accept', 'application/json') + headers.append('Content-Type', 'application/json') + headers.append('X-Api-Key', adminKey) + + const res = await fetch(url + path, { method: 'GET', headers }) + if (!res.ok) { + const errBody = await res.json() + throw new Error(errBody.detail) + } + const payment = await res.json() + return payment +} + +export function LNbitsProvider ({ children }) { + const [url, setUrl] = useState('') + const [adminKey, setAdminKey] = useState('') + const [enabled, setEnabled] = useState() + const [isDefault, setIsDefault] = useState() + + const name = 'LNbits' + const storageKey = 'webln:provider:lnbits' + + const getInfo = useCallback(async () => { + const response = await getWallet(url, adminKey) + return { + node: { + alias: response.name, + pubkey: '' + }, + methods: [ + 'getInfo', + 'getBalance', + 'sendPayment' + ], + version: '1.0', + supports: ['lightning'] + } + }, [url, adminKey]) + + const sendPayment = useCallback(async (bolt11) => { + const response = await postPayment(url, adminKey, bolt11) + const checkResponse = await getPayment(url, adminKey, response.payment_hash) + if (!checkResponse.preimage) { + throw new Error('No preimage') + } + return { preimage: checkResponse.preimage } + }, [url, adminKey]) + + const loadConfig = useCallback(async () => { + const configStr = window.localStorage.getItem(storageKey) + if (!configStr) { + setEnabled(undefined) + return + } + + const config = JSON.parse(configStr) + + const { url, adminKey, isDefault } = config + setUrl(url) + setAdminKey(adminKey) + setIsDefault(isDefault) + + try { + // validate config by trying to fetch wallet + await getWallet(url, adminKey) + setEnabled(true) + } catch (err) { + console.error('invalid LNbits config:', err) + setEnabled(false) + throw err + } + }, []) + + const saveConfig = useCallback(async (config) => { + // immediately store config so it's not lost even if config is invalid + setUrl(config.url) + setAdminKey(config.adminKey) + setIsDefault(config.isDefault) + + // XXX This is insecure, XSS vulns could lead to loss of funds! + // -> check how mutiny encrypts their wallet and/or check if we can leverage web workers + // https://thenewstack.io/leveraging-web-workers-to-safely-store-access-tokens/ + window.localStorage.setItem(storageKey, JSON.stringify(config)) + + try { + // validate config by trying to fetch wallet + await getWallet(config.url, config.adminKey) + } catch (err) { + console.error('invalid LNbits config:', err) + setEnabled(false) + throw err + } + setEnabled(true) + }, []) + + const clearConfig = useCallback(() => { + window.localStorage.removeItem(storageKey) + setUrl('') + setAdminKey('') + setEnabled(undefined) + }, []) + + useEffect(() => { + loadConfig().catch(console.error) + }, []) + + const value = { name, url, adminKey, saveConfig, clearConfig, enabled, isDefault, setIsDefault, getInfo, sendPayment } + return ( + + {children} + + ) +} + +export function useLNbits () { + return useContext(LNbitsContext) +} diff --git a/components/webln/nwc.js b/components/webln/nwc.js new file mode 100644 index 000000000..ac72209a1 --- /dev/null +++ b/components/webln/nwc.js @@ -0,0 +1,249 @@ +// https://github.com/getAlby/js-sdk/blob/master/src/webln/NostrWeblnProvider.ts + +import { createContext, useCallback, useContext, useEffect, useState } from 'react' +import { Relay, finalizeEvent, nip04 } from 'nostr-tools' + +const NWCContext = createContext() + +export function NWCProvider ({ children }) { + const [nwcUrl, setNwcUrl] = useState('') + const [walletPubkey, setWalletPubkey] = useState() + const [relayUrl, setRelayUrl] = useState() + const [secret, setSecret] = useState() + const [enabled, setEnabled] = useState() + const [isDefault, setIsDefault] = useState() + const [relay, setRelay] = useState() + + const name = 'NWC' + const storageKey = 'webln:provider:nwc' + + const loadConfig = useCallback(async () => { + const configStr = window.localStorage.getItem(storageKey) + if (!configStr) { + setEnabled(undefined) + return + } + + const config = JSON.parse(configStr) + + const { nwcUrl, isDefault } = config + setNwcUrl(nwcUrl) + setIsDefault(isDefault) + + const params = parseWalletConnectUrl(nwcUrl) + setRelayUrl(params.relayUrl) + setWalletPubkey(params.walletPubkey) + setSecret(params.secret) + + try { + const supported = await validateParams(params) + setEnabled(supported.includes('pay_invoice')) + } catch (err) { + console.error('invalid NWC config:', err) + setEnabled(false) + throw err + } + }, []) + + const saveConfig = useCallback(async (config) => { + // immediately store config so it's not lost even if config is invalid + const { nwcUrl, isDefault } = config + setNwcUrl(nwcUrl) + setIsDefault(isDefault) + if (!nwcUrl) { + setEnabled(undefined) + return + } + + const params = parseWalletConnectUrl(nwcUrl) + setRelayUrl(params.relayUrl) + setWalletPubkey(params.walletPubkey) + setSecret(params.secret) + + // XXX Even though NWC allows to configure budget, + // this is definitely not ideal from a security perspective. + window.localStorage.setItem(storageKey, JSON.stringify(config)) + + try { + const supported = await validateParams(params) + setEnabled(supported.includes('pay_invoice')) + } catch (err) { + console.error('invalid NWC config:', err) + setEnabled(false) + throw err + } + }, []) + + const clearConfig = useCallback(() => { + window.localStorage.removeItem(storageKey) + setNwcUrl('') + setRelayUrl(undefined) + setWalletPubkey(undefined) + setSecret(undefined) + setEnabled(undefined) + }, []) + + useEffect(() => { + let relay + (async function () { + if (relayUrl) { + relay = await Relay.connect(relayUrl) + setRelay(relay) + } + })().catch((err) => { + console.error(err) + setRelay(null) + }) + return () => { + relay?.close() + setRelay(null) + } + }, [relayUrl]) + + const sendPayment = useCallback((bolt11) => { + return new Promise(function (resolve, reject) { + (async function () { + // XXX set this to mock NWC relays + const MOCK_NWC_RELAY = false + + // timeout since NWC is async (user needs to confirm payment in wallet) + // timeout is same as invoice expiry + const timeout = MOCK_NWC_RELAY ? 3000 : 180_000 + let timer + const resetTimer = () => { + clearTimeout(timer) + timer = setTimeout(() => { + sub?.close() + if (MOCK_NWC_RELAY) { + const heads = Math.random() < 0.5 + if (heads) { + return resolve({ preimage: null }) + } + return reject(new Error('mock error')) + } + return reject(new Error('timeout')) + }, timeout) + } + if (MOCK_NWC_RELAY) return resetTimer() + + const payload = { + method: 'pay_invoice', + params: { invoice: bolt11 } + } + const content = await nip04.encrypt(secret, walletPubkey, JSON.stringify(payload)) + const request = finalizeEvent({ + kind: 23194, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', walletPubkey]], + content + }, secret) + await relay.publish(request) + resetTimer() + + const filter = { + kinds: [23195], + authors: [walletPubkey], + '#e': [request.id] + } + const sub = relay.subscribe([filter], { + async onevent (response) { + resetTimer() + try { + const content = JSON.parse(await nip04.decrypt(secret, walletPubkey, response.content)) + if (content.error) return reject(new Error(content.error.message)) + if (content.result) return resolve({ preimage: content.result.preimage }) + } catch (err) { + return reject(err) + } finally { + clearTimeout(timer) + sub.close() + } + }, + onclose (reason) { + clearTimeout(timer) + reject(new Error(reason)) + } + }) + })().catch(reject) + }) + }, [relay, walletPubkey, secret]) + + const getInfo = useCallback(() => getInfoWithRelay(relay, walletPubkey), [relay, walletPubkey]) + + useEffect(() => { + loadConfig().catch(console.error) + }, []) + + const value = { name, nwcUrl, relayUrl, walletPubkey, secret, saveConfig, clearConfig, enabled, isDefault, setIsDefault, getInfo, sendPayment } + return ( + + {children} + + ) +} + +export function useNWC () { + return useContext(NWCContext) +} + +async function validateParams ({ relayUrl, walletPubkey, secret }) { + let infoRelay + try { + // validate connection by fetching info event + infoRelay = await Relay.connect(relayUrl) + return await getInfoWithRelay(infoRelay, walletPubkey) + } finally { + infoRelay?.close() + } +} + +async function getInfoWithRelay (relay, walletPubkey) { + return await new Promise((resolve, reject) => { + const timeout = 5000 + const timer = setTimeout(() => { + reject(new Error('timeout waiting for response')) + sub?.close() + }, timeout) + + const sub = relay.subscribe([ + { + kinds: [13194], + authors: [walletPubkey] + } + ], { + onevent (event) { + clearTimeout(timer) + const supported = event.content.split() + resolve(supported) + sub.close() + }, + onclose (reason) { + clearTimeout(timer) + reject(new Error(reason)) + }, + oneose () { + clearTimeout(timer) + reject(new Error('info event not found')) + } + }) + }) +} + +function parseWalletConnectUrl (walletConnectUrl) { + walletConnectUrl = walletConnectUrl + .replace('nostrwalletconnect://', 'http://') + .replace('nostr+walletconnect://', 'http://') // makes it possible to parse with URL in the different environments (browser/node/...) + + const url = new URL(walletConnectUrl) + const params = {} + params.walletPubkey = url.host + const secret = url.searchParams.get('secret') + const relayUrl = url.searchParams.get('relay') + if (secret) { + params.secret = secret + } + if (relayUrl) { + params.relayUrl = relayUrl + } + return params +} diff --git a/docs/attach-lnbits.md b/docs/attach-lnbits.md new file mode 100644 index 000000000..b64924ead --- /dev/null +++ b/docs/attach-lnbits.md @@ -0,0 +1,81 @@ +# attach lnbits + +To test sending from an attached wallet, it's easiest to use [lnbits](https://lnbits.com/) hooked up to a [local lnd node](./local-lnd.md) in your regtest network. + +This will attempt to walk you through setting up lnbits with docker and connecting it to your local lnd node. + +🚨 this a dev guide. do not use this guide for real funds 🚨 + +From [this guide](https://docs.lnbits.org/guide/installation.html#option-3-docker): + +## 1. pre-configuration + +Create a directory for lnbits, get the sample environment file, and create a shared data directory for lnbits to use: + +```bash +mkdir lnbits +cd lnbits +wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env +mkdir data +``` + +## 2. configure + +To configure lnbits to use a [local lnd node](./local-lnd.md) in your regtest network, go to [polar](https://lightningpolar.com/) and click on the LND node you want to use as a funding source. Then click on `Connect`. + +In the `Connect` tab, click the `File paths` tab and copy the `TLS cert` and `Admin macaroon` files to the `data` directory you created earlier. + +```bash +cp /path/to/tls.cert /path/to/admin.macaroon data/ +``` + +Then, open the `.env` file you created and override the following values: + +```bash +LNBITS_ADMIN_UI=true +LNBITS_BACKEND_WALLET_CLASS=LndWallet +LND_GRPC_ENDPOINT=host.docker.internal +LND_GRPC_PORT=${Port from the polar connect page} +LND_GRPC_CERT=data/tls.cert +LND_GRPC_MACAROON=data/admin.macaroon +``` + +## 2. Install and run lnbits + +Pull the latest image: + +```bash +docker pull lnbitsdocker/lnbits-legend +docker run --detach --publish 5001:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbitsdocker/lnbits-legend +``` + +Note: we make lnbits available on the host's port 5001 here (on Mac, 5000 is used by AirPlay), but you can change that to whatever you want. + +## 3. Accessing the admin wallet + +By enabling the [Admin UI](https://docs.lnbits.org/guide/admin_ui.html), lnbits creates a so called super_user. Get this super_user id by running: + +```bash +cat data/.super_user +``` + +Open your browser and go to `http://localhost:5001/wallet?usr=${super_user id from above}`. LNBits will redirect you to a default wallet we will use called `LNBits wallet`. + +## 4. Fund the wallet + +To fund `LNBits wallet`, click the `+` next the wallet balance. Enter the number of sats you want to credit the wallet and hit enter. + +## 5. Attach the wallet to stackernews + +Open up your local stackernews, go to `http://localhost:3000/settings/wallets` and click on `attach` in the `lnbits` card. + +In the form, fill in `lnbits url` with `http://localhost:5001`. + +Back in lnbits click on `API Docs` in the right pane. Copy the Admin key and paste it into the `admin key` field in the form. + +Click `attach` and you should be good to go. + +## Debugging + +- you can view lnbits logs with `docker logs lnbits` or in `data/logs/` in the `data` directory you created earlier +- with the [Admin UI](https://docs.lnbits.org/guide/admin_ui.html), you can modify LNBits in the GUI by clicking `Server` in left pane \ No newline at end of file diff --git a/lib/validate.js b/lib/validate.js index 30d8dc0fc..2526b4d5f 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -421,6 +421,19 @@ export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) => return accum }, {}))) +export const lnbitsSchema = object({ + url: process.env.NODE_ENV === 'development' + ? string().or( + [string().matches(/^(http:\/\/)?localhost:\d+$/), string().url()], + 'invalid url').required('required').trim() + : string().url().required('required').trim(), + adminKey: string().length(32) +}) + +export const nwcSchema = object({ + nwcUrl: string().required('required').trim().matches(/^nostr\+walletconnect:/) +}) + export const bioSchema = object({ bio: string().required('required').trim() }) diff --git a/package-lock.json b/package-lock.json index 6984fb928..2360d1ae2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "node-s3-url-encode": "^0.0.4", "nodemailer": "^6.9.6", "nostr": "^0.2.8", + "nostr-tools": "^2.1.5", "nprogress": "^0.2.0", "opentimestamps": "^0.4.9", "page-metadata-parser": "^1.1.4", @@ -2654,6 +2655,14 @@ "semver": "bin/semver.js" } }, + "node_modules/@noble/ciphers": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.2.0.tgz", + "integrity": "sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw==", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/curves": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", @@ -2961,6 +2970,64 @@ "rollup": "^1.20.0||^2.0.0" } }, + "node_modules/@scure/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", + "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ] + }, + "node_modules/@scure/bip32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz", + "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==", + "dependencies": { + "@noble/curves": "~1.1.0", + "@noble/hashes": "~1.3.1", + "@scure/base": "~1.1.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz", + "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==", + "dependencies": { + "@noble/hashes": "1.3.1" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz", + "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", + "dependencies": { + "@noble/hashes": "~1.3.0", + "@scure/base": "~1.1.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -10315,6 +10382,47 @@ "ws": "^8.8.1" } }, + "node_modules/nostr-tools": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.1.5.tgz", + "integrity": "sha512-Gug/j54YGQ0ewB09dZW3mS9qfXWFlcOQMlyb1MmqQsuNO/95mfNOQSBi+jZ61O++Y+jG99SzAUPFLopUsKf0MA==", + "dependencies": { + "@noble/ciphers": "0.2.0", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.1", + "@scure/base": "1.1.1", + "@scure/bip32": "1.3.1", + "@scure/bip39": "1.2.1" + }, + "optionalDependencies": { + "nostr-wasm": "v0.1.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-tools/node_modules/@noble/hashes": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "optional": true + }, "node_modules/nprogress": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", diff --git a/package.json b/package.json index c4b60955e..c4bae7d72 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "node-s3-url-encode": "^0.0.4", "nodemailer": "^6.9.6", "nostr": "^0.2.8", + "nostr-tools": "^2.1.5", "nprogress": "^0.2.0", "opentimestamps": "^0.4.9", "page-metadata-parser": "^1.1.4", diff --git a/pages/_app.js b/pages/_app.js index 64129b586..b537811dd 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -18,6 +18,7 @@ import NProgress from 'nprogress' import 'nprogress/nprogress.css' import { LoggerProvider } from '../components/logger' import { ChainFeeProvider } from '../components/chain-fee.js' +import { WebLNProvider } from '../components/webln' import dynamic from 'next/dynamic' const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false }) @@ -98,16 +99,18 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - - - - - - - - - - + + + + + + + + + + + + diff --git a/pages/settings/wallets/index.js b/pages/settings/wallets/index.js index bcb34d845..a03b5589b 100644 --- a/pages/settings/wallets/index.js +++ b/pages/settings/wallets/index.js @@ -3,6 +3,8 @@ import Layout from '../../../components/layout' import styles from '../../../styles/wallet.module.css' import { WalletCard } from '../../../components/wallet-card' import { LightningAddressWalletCard } from './lightning-address' +import { LNbitsCard } from './lnbits' +import { NWCCard } from './nwc' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) @@ -14,6 +16,8 @@ export default function Wallet () {
attach wallets to supplement your SN wallet
+ + diff --git a/pages/settings/wallets/lnbits.js b/pages/settings/wallets/lnbits.js new file mode 100644 index 000000000..2d137d163 --- /dev/null +++ b/pages/settings/wallets/lnbits.js @@ -0,0 +1,88 @@ +import { getGetServerSideProps } from '../../../api/ssrApollo' +import { Form, ClientInput, ClientCheckbox } from '../../../components/form' +import { CenterLayout } from '../../../components/layout' +import { WalletButtonBar, WalletCard } from '../../../components/wallet-card' +import { lnbitsSchema } from '../../../lib/validate' +import { useToast } from '../../../components/toast' +import { useRouter } from 'next/router' +import { useLNbits } from '../../../components/webln/lnbits' +import { WalletSecurityBanner } from '../../../components/banners' + +export const getServerSideProps = getGetServerSideProps({ authRequired: true }) + +export default function LNbits () { + const { url, adminKey, saveConfig, clearConfig, enabled, isDefault } = useLNbits() + const toaster = useToast() + const router = useRouter() + + return ( + +

LNbits

+
use LNbits for payments
+ +
{ + try { + await saveConfig(values) + toaster.success('saved settings') + router.push('/settings/wallets') + } catch (err) { + console.error(err) + toaster.danger('failed to attach: ' + err.message || err.toString?.()) + } + }} + > + + + + { + try { + await clearConfig() + toaster.success('saved settings') + router.push('/settings/wallets') + } catch (err) { + console.error(err) + toaster.danger('failed to unattach: ' + err.message || err.toString?.()) + } + }} + /> + +
+ ) +} + +export function LNbitsCard () { + const { enabled } = useLNbits() + return ( + + ) +} diff --git a/pages/settings/wallets/nwc.js b/pages/settings/wallets/nwc.js new file mode 100644 index 000000000..059bedd10 --- /dev/null +++ b/pages/settings/wallets/nwc.js @@ -0,0 +1,80 @@ +import { getGetServerSideProps } from '../../../api/ssrApollo' +import { Form, ClientInput, ClientCheckbox } from '../../../components/form' +import { CenterLayout } from '../../../components/layout' +import { WalletButtonBar, WalletCard } from '../../../components/wallet-card' +import { nwcSchema } from '../../../lib/validate' +import { useToast } from '../../../components/toast' +import { useRouter } from 'next/router' +import { useNWC } from '../../../components/webln/nwc' +import { WalletSecurityBanner } from '../../../components/banners' + +export const getServerSideProps = getGetServerSideProps({ authRequired: true }) + +export default function NWC () { + const { nwcUrl, saveConfig, clearConfig, enabled, isDefault } = useNWC() + const toaster = useToast() + const router = useRouter() + + return ( + +

Nostr Wallet Connect

+
use Nostr Wallet Connect for payments
+ +
{ + try { + await saveConfig(values) + toaster.success('saved settings') + router.push('/settings/wallets') + } catch (err) { + console.error(err) + toaster.danger('failed to attach: ' + err.message || err.toString?.()) + } + }} + > + + + { + try { + await clearConfig() + toaster.success('saved settings') + router.push('/settings/wallets') + } catch (err) { + console.error(err) + toaster.danger('failed to unattach: ' + err.message || err.toString?.()) + } + }} + /> + +
+ ) +} + +export function NWCCard () { + const { enabled } = useNWC() + return ( + + ) +} diff --git a/styles/wallet.module.css b/styles/wallet.module.css index d72bea30a..a9bc1e63e 100644 --- a/styles/wallet.module.css +++ b/styles/wallet.module.css @@ -48,6 +48,13 @@ filter: drop-shadow(0 0 2px #20c997); } +.indicator.error { + color: var(--bs-red) !important; + background-color: var(--bs-red) !important; + border: 1px solid var(--bs-danger); + filter: drop-shadow(0 0 2px #c92020); +} + .indicator.disabled { color: var(--theme-toolbarHover) !important; background-color: var(--theme-toolbarHover) !important;