From 3177672fca0e5b033c3bfadcdd1b31f7bb43df1b Mon Sep 17 00:00:00 2001 From: ekzyis Date: Tue, 26 Dec 2023 18:43:21 +0100 Subject: [PATCH 01/75] Add LNbits card --- lib/validate.js | 5 ++ pages/settings/wallets/index.js | 2 + pages/settings/wallets/lnbits.js | 102 +++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 pages/settings/wallets/lnbits.js diff --git a/lib/validate.js b/lib/validate.js index 30d8dc0fc..4f2166e39 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -421,6 +421,11 @@ export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) => return accum }, {}))) +export const lnbitsSchema = object({ + url: string().url().required('required').trim(), + adminKey: string().length(32) +}) + export const bioSchema = object({ bio: string().required('required').trim() }) diff --git a/pages/settings/wallets/index.js b/pages/settings/wallets/index.js index bcb34d845..ce8f9f6a0 100644 --- a/pages/settings/wallets/index.js +++ b/pages/settings/wallets/index.js @@ -3,6 +3,7 @@ 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' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) @@ -14,6 +15,7 @@ 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..e9ecec1cb --- /dev/null +++ b/pages/settings/wallets/lnbits.js @@ -0,0 +1,102 @@ +import { getGetServerSideProps } from '../../../api/ssrApollo' +import { Form, Input } 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 { useCallback, useEffect, useState } from 'react' + +export const getServerSideProps = getGetServerSideProps({ authRequired: true }) + +const useLNbits = () => { + const [config, setConfig] = useState(null) + const storageKey = 'lnbitsConfig' + + useEffect(() => { + const config = window.localStorage.getItem(storageKey) + if (config) setConfig(JSON.parse(config)) + }, []) + + const setLNbits = useCallback(({ url, adminKey }) => { + const config = { url, adminKey } + // TODO encrypt credentials / see how mutiny encrypts wallets + window.localStorage.setItem(storageKey, JSON.stringify(config)) + setConfig(config) + }, []) + + const removeLNbits = useCallback(() => { + window.localStorage.removeItem(storageKey) + setConfig(null) + }) + + return { config, isEnabled: !!config, setLNbits, removeLNbits } +} + +export default function LNbits () { + const { config, isEnabled, setLNbits, removeLNbits } = useLNbits() + const toaster = useToast() + const router = useRouter() + + return ( + +

lnbits

+
use lnbits for zapping
+
{ + try { + await setLNbits(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 removeLNbits() + 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 { isEnabled } = useLNbits() + return ( + + ) +} From 3e8af2ff5443168fa83b8098dd1015d95adbbea7 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Tue, 26 Dec 2023 20:49:33 +0100 Subject: [PATCH 02/75] Save LNbits Provider in WebLN context --- components/webln.js | 57 ++++++++++++++++++++++++++++++++ pages/_app.js | 51 ++++++++++++++-------------- pages/settings/wallets/lnbits.js | 34 +++---------------- 3 files changed, 89 insertions(+), 53 deletions(-) create mode 100644 components/webln.js diff --git a/components/webln.js b/components/webln.js new file mode 100644 index 000000000..a9e848fa3 --- /dev/null +++ b/components/webln.js @@ -0,0 +1,57 @@ +import { createContext, createRef, useCallback, useContext, useEffect, useState } from 'react' + +const WebLNContext = createContext({}) +export const WebLNContextRef = createRef() + +const lnbits = { + storageKey: 'webln:provider:lnbits', + load () { + const config = window.localStorage.getItem(this.storageKey) + if (config) return JSON.parse(config) + return null + }, + save (config) { + // 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(this.storageKey, JSON.stringify(config)) + }, + remove () { + window.localStorage.removeItem(this.storageKey) + } +} + +const providers = { lnbits } + +export function WebLNProvider ({ children }) { + const [provider, setProvider] = useState({}) + + useEffect(() => { + // init providers on client + setProvider(p => ({ ...p, lnbits: providers.lnbits.load() })) + // TODO support more WebLN providers + }, []) + + return ( + + {children} + + ) +} + +export function useWebLN (key) { + const { provider, setProvider } = useContext(WebLNContext) + const config = provider[key] + + const setConfig = useCallback((config) => { + providers[key].save(config) + setProvider(p => ({ ...p, [key]: config })) + }, []) + + const clearConfig = () => { + providers[key].remove?.() + setProvider(p => ({ ...p, [key]: null })) + } + + return { config, setConfig, clearConfig, isEnabled: !!config } +} diff --git a/pages/_app.js b/pages/_app.js index 64129b586..7847d1267 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 }) @@ -91,30 +92,32 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pages/settings/wallets/lnbits.js b/pages/settings/wallets/lnbits.js index e9ecec1cb..fe687b0df 100644 --- a/pages/settings/wallets/lnbits.js +++ b/pages/settings/wallets/lnbits.js @@ -5,36 +5,12 @@ import { WalletButtonBar, WalletCard } from '../../../components/wallet-card' import { lnbitsSchema } from '../../../lib/validate' import { useToast } from '../../../components/toast' import { useRouter } from 'next/router' -import { useCallback, useEffect, useState } from 'react' +import { useWebLN } from '../../../components/webln' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) -const useLNbits = () => { - const [config, setConfig] = useState(null) - const storageKey = 'lnbitsConfig' - - useEffect(() => { - const config = window.localStorage.getItem(storageKey) - if (config) setConfig(JSON.parse(config)) - }, []) - - const setLNbits = useCallback(({ url, adminKey }) => { - const config = { url, adminKey } - // TODO encrypt credentials / see how mutiny encrypts wallets - window.localStorage.setItem(storageKey, JSON.stringify(config)) - setConfig(config) - }, []) - - const removeLNbits = useCallback(() => { - window.localStorage.removeItem(storageKey) - setConfig(null) - }) - - return { config, isEnabled: !!config, setLNbits, removeLNbits } -} - export default function LNbits () { - const { config, isEnabled, setLNbits, removeLNbits } = useLNbits() + const { config, setConfig, clearConfig, isEnabled } = useWebLN('lnbits') const toaster = useToast() const router = useRouter() @@ -51,7 +27,7 @@ export default function LNbits () { schema={lnbitsSchema} onSubmit={async (values) => { try { - await setLNbits(values) + await setConfig(values) toaster.success('saved settings') router.push('/settings/wallets') } catch (err) { @@ -75,7 +51,7 @@ export default function LNbits () { { try { - await removeLNbits() + await clearConfig() toaster.success('saved settings') router.push('/settings/wallets') } catch (err) { @@ -90,7 +66,7 @@ export default function LNbits () { } export function LNbitsCard () { - const { isEnabled } = useLNbits() + const { isEnabled } = useWebLN('lnbits') return ( Date: Sun, 14 Jan 2024 02:30:32 +0100 Subject: [PATCH 03/75] Check LNbits connection on save --- components/webln.js | 127 ++++++++++++++++++++++++++----- pages/settings/wallets/lnbits.js | 12 +-- 2 files changed, 112 insertions(+), 27 deletions(-) diff --git a/components/webln.js b/components/webln.js index a9e848fa3..bfced4fb3 100644 --- a/components/webln.js +++ b/components/webln.js @@ -5,53 +5,138 @@ export const WebLNContextRef = createRef() const lnbits = { storageKey: 'webln:provider:lnbits', - load () { + _url: null, + _adminKey: null, + enabled: false, + async load () { const config = window.localStorage.getItem(this.storageKey) - if (config) return JSON.parse(config) - return null + if (!config) return null + const configJSON = JSON.parse(config) + this._url = configJSON.url + this._adminKey = configJSON.adminKey + try { + await this.updateEnabled() + } catch (err) { + console.error(err) + } + return configJSON }, - save (config) { + async save (config) { + this._url = config.url + this._adminKey = config.adminKey + await this.updateEnabled() // 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(this.storageKey, JSON.stringify(config)) }, - remove () { + clear () { window.localStorage.removeItem(this.storageKey) + this._url = null + this._adminKey = null + this.enabled = false + }, + async updateEnabled () { + if (!(this._url && this._adminKey)) { + this.enabled = false + return + } + await this.getInfo() + this.enabled = true + }, + async _request (method, path, args) { + // https://github.com/getAlby/bitcoin-connect/blob/v3.2.0-alpha/src/connectors/LnbitsConnector.ts + let body = null + const query = '' + const headers = new Headers() + headers.append('Accept', 'application/json') + headers.append('Content-Type', 'application/json') + headers.append('X-Api-Key', this._adminKey) + + if (method === 'POST') { + body = JSON.stringify(args) + } else if (args !== undefined) { + throw new Error('TODO: support args in GET') + // query = ... + } + const url = this._url.replace(/\/+$/, '') + const res = await fetch(url + path + query, { + method, + headers, + body + }) + if (!res.ok) { + const errBody = await res.json() + throw new Error(errBody.detail) + } + return (await res.json()) + }, + async getInfo () { + // https://github.com/getAlby/bitcoin-connect/blob/v3.2.0-alpha/src/connectors/LnbitsConnector.ts + const response = await this._request( + 'GET', + '/api/v1/wallet' + ) + + return { + node: { + alias: response.name, + pubkey: '' + }, + methods: [ + 'getInfo', + 'getBalance', + 'sendPayment' + // TODO: support makeInvoice and sendPaymentAsync + ], + version: '1.0', + supports: ['lightning'] + } } } -const providers = { lnbits } +const _providers = { lnbits } export function WebLNProvider ({ children }) { - const [provider, setProvider] = useState({}) + const [providers, setProviders] = useState(_providers) useEffect(() => { + const initProvider = async key => { + const config = await _providers[key].load() + const { enabled } = _providers[key] + setProviders(p => ({ ...p, [key]: { config, enabled } })) + } // init providers on client - setProvider(p => ({ ...p, lnbits: providers.lnbits.load() })) + initProvider('lnbits') // TODO support more WebLN providers }, []) + const setConfig = useCallback(async (key, config) => { + await _providers[key].save(config) + const { enabled } = _providers[key] + setProviders(p => ({ ...p, [key]: { ...p[key], config, enabled } })) + }, [providers]) + + const clearConfig = useCallback(async (key) => { + await _providers[key].clear() + const { enabled } = _providers[key] + setProviders(p => ({ ...p, [key]: { ...p[key], config: null, enabled } })) + }, [providers]) + return ( - + {children} ) } export function useWebLN (key) { - const { provider, setProvider } = useContext(WebLNContext) - const config = provider[key] + const { provider, setConfig: _setConfig, clearConfig: _clearConfig } = useContext(WebLNContext) - const setConfig = useCallback((config) => { - providers[key].save(config) - setProvider(p => ({ ...p, [key]: config })) - }, []) - - const clearConfig = () => { - providers[key].remove?.() - setProvider(p => ({ ...p, [key]: null })) - } + const p = provider[key] + const { config, enabled } = p + const setConfig = (config) => _setConfig(key, config) + const clearConfig = () => _clearConfig(key) - return { config, setConfig, clearConfig, isEnabled: !!config } + return { config, setConfig, clearConfig, enabled } } diff --git a/pages/settings/wallets/lnbits.js b/pages/settings/wallets/lnbits.js index fe687b0df..1438eec6e 100644 --- a/pages/settings/wallets/lnbits.js +++ b/pages/settings/wallets/lnbits.js @@ -10,7 +10,7 @@ import { useWebLN } from '../../../components/webln' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) export default function LNbits () { - const { config, setConfig, clearConfig, isEnabled } = useWebLN('lnbits') + const { config, setConfig, clearConfig, enabled } = useWebLN('lnbits') const toaster = useToast() const router = useRouter() @@ -32,7 +32,7 @@ export default function LNbits () { router.push('/settings/wallets') } catch (err) { console.error(err) - toaster.danger('failed to attach:' + err.message || err.toString?.()) + toaster.danger('failed to attach: ' + err.message || err.toString?.()) } }} > @@ -49,14 +49,14 @@ export default function LNbits () { name='adminKey' /> { + enabled={enabled} onDelete={async () => { 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?.()) + toaster.danger('failed to unattach: ' + err.message || err.toString?.()) } }} /> @@ -66,13 +66,13 @@ export default function LNbits () { } export function LNbitsCard () { - const { isEnabled } = useWebLN('lnbits') + const { enabled } = useWebLN('lnbits') return ( ) } From 76ba4d7f3705289b3074bd3d14d0d6020d817f01 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 14 Jan 2024 03:47:15 +0100 Subject: [PATCH 04/75] refactor: put LNbitsProvider into own file --- components/webln/index.js | 50 +++++++++++ components/{webln.js => webln/lnbits.js} | 108 ++++++----------------- 2 files changed, 78 insertions(+), 80 deletions(-) create mode 100644 components/webln/index.js rename components/{webln.js => webln/lnbits.js} (58%) diff --git a/components/webln/index.js b/components/webln/index.js new file mode 100644 index 000000000..94bfa1de8 --- /dev/null +++ b/components/webln/index.js @@ -0,0 +1,50 @@ +import { createContext, useCallback, useContext, useEffect, useState } from 'react' +import LNbitsProvider from './lnbits' + +const WebLNContext = createContext({}) + +const _providers = { lnbits: LNbitsProvider } + +export function WebLNProvider ({ children }) { + const [providers, setProviders] = useState(_providers) + + useEffect(() => { + const initProvider = async key => { + const config = await _providers[key].load() + const { enabled } = _providers[key] + setProviders(p => ({ ...p, [key]: { config, enabled } })) + } + // init providers on client + initProvider('lnbits') + // TODO support more WebLN providers + }, []) + + const setConfig = useCallback(async (key, config) => { + await _providers[key].save(config) + const { enabled } = _providers[key] + setProviders(p => ({ ...p, [key]: { ...p[key], config, enabled } })) + }, [providers]) + + const clearConfig = useCallback(async (key) => { + await _providers[key].clear() + const { enabled } = _providers[key] + setProviders(p => ({ ...p, [key]: { ...p[key], config: null, enabled } })) + }, [providers]) + + return ( + + {children} + + ) +} + +export function useWebLN (key) { + const { provider, setConfig: _setConfig, clearConfig: _clearConfig } = useContext(WebLNContext) + + const p = provider[key] + const { config, enabled } = p + const setConfig = (config) => _setConfig(key, config) + const clearConfig = () => _clearConfig(key) + + return { config, setConfig, clearConfig, enabled } +} diff --git a/components/webln.js b/components/webln/lnbits.js similarity index 58% rename from components/webln.js rename to components/webln/lnbits.js index bfced4fb3..3653f343f 100644 --- a/components/webln.js +++ b/components/webln/lnbits.js @@ -1,9 +1,4 @@ -import { createContext, createRef, useCallback, useContext, useEffect, useState } from 'react' - -const WebLNContext = createContext({}) -export const WebLNContextRef = createRef() - -const lnbits = { +export default { storageKey: 'webln:provider:lnbits', _url: null, _adminKey: null, @@ -15,7 +10,7 @@ const lnbits = { this._url = configJSON.url this._adminKey = configJSON.adminKey try { - await this.updateEnabled() + await this._updateEnabled() } catch (err) { console.error(err) } @@ -24,7 +19,7 @@ const lnbits = { async save (config) { this._url = config.url this._adminKey = config.adminKey - await this.updateEnabled() + await this._updateEnabled() // 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/ @@ -36,13 +31,26 @@ const lnbits = { this._adminKey = null this.enabled = false }, - async updateEnabled () { - if (!(this._url && this._adminKey)) { - this.enabled = false - return + async getInfo () { + // https://github.com/getAlby/bitcoin-connect/blob/v3.2.0-alpha/src/connectors/LnbitsConnector.ts + const response = await this._request( + 'GET', + '/api/v1/wallet' + ) + return { + node: { + alias: response.name, + pubkey: '' + }, + methods: [ + 'getInfo', + 'getBalance', + 'sendPayment' + // TODO: support makeInvoice and sendPaymentAsync + ], + version: '1.0', + supports: ['lightning'] } - await this.getInfo() - this.enabled = true }, async _request (method, path, args) { // https://github.com/getAlby/bitcoin-connect/blob/v3.2.0-alpha/src/connectors/LnbitsConnector.ts @@ -71,72 +79,12 @@ const lnbits = { } return (await res.json()) }, - async getInfo () { - // https://github.com/getAlby/bitcoin-connect/blob/v3.2.0-alpha/src/connectors/LnbitsConnector.ts - const response = await this._request( - 'GET', - '/api/v1/wallet' - ) - - return { - node: { - alias: response.name, - pubkey: '' - }, - methods: [ - 'getInfo', - 'getBalance', - 'sendPayment' - // TODO: support makeInvoice and sendPaymentAsync - ], - version: '1.0', - supports: ['lightning'] + async _updateEnabled () { + if (!(this._url && this._adminKey)) { + this.enabled = false + return } + await this.getInfo() + this.enabled = true } } - -const _providers = { lnbits } - -export function WebLNProvider ({ children }) { - const [providers, setProviders] = useState(_providers) - - useEffect(() => { - const initProvider = async key => { - const config = await _providers[key].load() - const { enabled } = _providers[key] - setProviders(p => ({ ...p, [key]: { config, enabled } })) - } - // init providers on client - initProvider('lnbits') - // TODO support more WebLN providers - }, []) - - const setConfig = useCallback(async (key, config) => { - await _providers[key].save(config) - const { enabled } = _providers[key] - setProviders(p => ({ ...p, [key]: { ...p[key], config, enabled } })) - }, [providers]) - - const clearConfig = useCallback(async (key) => { - await _providers[key].clear() - const { enabled } = _providers[key] - setProviders(p => ({ ...p, [key]: { ...p[key], config: null, enabled } })) - }, [providers]) - - return ( - - {children} - - ) -} - -export function useWebLN (key) { - const { provider, setConfig: _setConfig, clearConfig: _clearConfig } = useContext(WebLNContext) - - const p = provider[key] - const { config, enabled } = p - const setConfig = (config) => _setConfig(key, config) - const clearConfig = () => _clearConfig(key) - - return { config, setConfig, clearConfig, enabled } -} From 8ef1159e443b695112c95940c063e5899418bc4c Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 14 Jan 2024 04:13:49 +0100 Subject: [PATCH 05/75] Pay invoices using WebLN provider from context --- components/qr.js | 8 ++++++-- components/webln/index.js | 20 ++++++++++++++++---- components/webln/lnbits.js | 23 +++++++++++++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/components/qr.js b/components/qr.js index 5a7f866a9..4209c9737 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) + toaster.danger(`${provider.name}: ${e.message}`) } } } diff --git a/components/webln/index.js b/components/webln/index.js index 94bfa1de8..6bdc7eb73 100644 --- a/components/webln/index.js +++ b/components/webln/index.js @@ -11,8 +11,15 @@ export function WebLNProvider ({ children }) { useEffect(() => { const initProvider = async key => { const config = await _providers[key].load() - const { enabled } = _providers[key] - setProviders(p => ({ ...p, [key]: { config, enabled } })) + const { sendPayment, enabled } = _providers[key] + setProviders(p => ({ + ...p, + [key]: { + config, + enabled, + sendPayment: sendPayment.bind(_providers[key]) + } + })) } // init providers on client initProvider('lnbits') @@ -41,10 +48,15 @@ export function WebLNProvider ({ children }) { export function useWebLN (key) { const { provider, setConfig: _setConfig, clearConfig: _clearConfig } = useContext(WebLNContext) + if (!key) { + // TODO pick preferred enabled WebLN provider here + key = 'lnbits' + } + const p = provider[key] - const { config, enabled } = p + const { config, enabled, sendPayment } = p const setConfig = (config) => _setConfig(key, config) const clearConfig = () => _clearConfig(key) - return { config, setConfig, clearConfig, enabled } + return { name: key, config, setConfig, clearConfig, enabled, sendPayment } } diff --git a/components/webln/lnbits.js b/components/webln/lnbits.js index 3653f343f..c7cf9c052 100644 --- a/components/webln/lnbits.js +++ b/components/webln/lnbits.js @@ -52,7 +52,30 @@ export default { supports: ['lightning'] } }, + async sendPayment (bolt11) { + const response = await this._request( + 'POST', + '/api/v1/payments', + { + bolt11, + out: true + } + ) + + const checkResponse = await this._request( + 'GET', + `/api/v1/payments/${response.payment_hash}` + ) + + if (!checkResponse.preimage) { + throw new Error('No preimage') + } + return { + preimage: checkResponse.preimage + } + }, async _request (method, path, args) { + if (!(this._url && this._adminKey)) throw new Error('provider not configured') // https://github.com/getAlby/bitcoin-connect/blob/v3.2.0-alpha/src/connectors/LnbitsConnector.ts let body = null const query = '' From 9cd32b5a4f5e6145fb3925b4c1545bc92f74974a Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 14 Jan 2024 04:47:11 +0100 Subject: [PATCH 06/75] Remove deprecated FIXME --- pages/settings/wallets/lnbits.js | 1 - 1 file changed, 1 deletion(-) diff --git a/pages/settings/wallets/lnbits.js b/pages/settings/wallets/lnbits.js index 1438eec6e..bbaf4d23f 100644 --- a/pages/settings/wallets/lnbits.js +++ b/pages/settings/wallets/lnbits.js @@ -19,7 +19,6 @@ export default function LNbits () {

lnbits

use lnbits for zapping
Date: Tue, 16 Jan 2024 04:40:37 +0100 Subject: [PATCH 07/75] Try WebLN provider first --- components/invoice.js | 65 +++++++++++++++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 14 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index 034955f43..d48f48691 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,6 +12,7 @@ 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 }) { const [expired, setExpired] = useState(new Date(invoice.expiredAt) <= new Date()) @@ -161,6 +162,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { mutation createInvoice($amount: Int!) { createInvoice(amount: $amount, hodlInvoice: true, expireSecs: 180) { id + bolt11 hash hmac expiresAt @@ -175,6 +177,9 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { `) const showModal = useShowModal() + const provider = useWebLN() + const client = useApolloClient() + const pollInvoice = (id) => client.query({ query: INVOICE, variables: { id } }) const onSubmitWrapper = useCallback(async ({ cost, ...formValues }, ...submitArgs) => { // some actions require a session @@ -206,24 +211,13 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { const inv = data.createInvoice // 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 modalClose = await waitForPayment(inv, showModal, provider, pollInvoice) const retry = () => onSubmit({ hash: inv.hash, hmac: inv.hmac, ...formValues }, ...submitArgs) // first retry try { const ret = await retry() - modalClose() + modalClose?.() return ret } catch (error) { console.error('retry error:', error) @@ -255,6 +249,49 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { return onSubmitWrapper } +const waitForPayment = async (inv, showModal, provider, pollInvoice) => { + try { + // try WebLN provider first + return await new Promise((resolve, reject) => { + // don't use await here since we're using HODL invoices + // and sendPaymentAsync is not supported yet. + // see https://www.webln.guide/building-lightning-apps/webln-reference/webln.sendpaymentasync + provider.sendPayment(inv.bolt11) + const interval = setInterval(async () => { + try { + const { data, error } = await pollInvoice(inv.id) + if (error) { + clearInterval(interval) + return reject(error) + } + const { invoice } = data + if (invoice.isHeld && invoice.satsReceived) { + clearInterval(interval) + resolve() + } + } catch (err) { + clearInterval(interval) + reject(err) + } + }, 1000) + }) + } catch (err) { + console.error('WebLN payment failed:', err) + } + + // QR code as fallback + return await new Promise((resolve, reject) => { + showModal(onClose => { + return ( + resolve(onClose)} + /> + ) + }, { keepOpen: true, onClose: reject }) + }) +} + export const useInvoiceModal = (onPayment, deps) => { const onPaymentMemo = useCallback(onPayment, deps) return useInvoiceable(onPaymentMemo, { replaceModal: true }) From d62cfbe1a80bbc1c7e2abb8866afe5f032c954db Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 17 Jan 2024 22:00:00 +0100 Subject: [PATCH 08/75] Fix unhandled promise rejection --- components/invoice.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/components/invoice.js b/components/invoice.js index d48f48691..6342a34e7 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -253,10 +253,17 @@ const waitForPayment = async (inv, showModal, provider, pollInvoice) => { try { // try WebLN provider first return await new Promise((resolve, reject) => { - // don't use await here since we're using HODL invoices + // 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(inv.bolt11) + // WebLN payment will never resolve here for HODL invoices + // since they only get resolved after settlement which can't happen here + .then(resolve) + .catch(err => { + clearInterval(interval) + reject(err) + }) const interval = setInterval(async () => { try { const { data, error } = await pollInvoice(inv.id) From 860c54a3cd3db5f09c1eb9c5ae7470e0fe95cc37 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 18 Jan 2024 02:52:09 +0100 Subject: [PATCH 09/75] Fix this in sendPayment --- components/webln/index.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/components/webln/index.js b/components/webln/index.js index 6bdc7eb73..ab1d07e4b 100644 --- a/components/webln/index.js +++ b/components/webln/index.js @@ -11,13 +11,13 @@ export function WebLNProvider ({ children }) { useEffect(() => { const initProvider = async key => { const config = await _providers[key].load() - const { sendPayment, enabled } = _providers[key] + const pkey = _providers[key] setProviders(p => ({ ...p, [key]: { config, - enabled, - sendPayment: sendPayment.bind(_providers[key]) + enabled: pkey.enabled, + sendPayment: pkey.sendPayment.bind(pkey) } })) } @@ -54,9 +54,9 @@ export function useWebLN (key) { } const p = provider[key] - const { config, enabled, sendPayment } = p + const { config, enabled } = p const setConfig = (config) => _setConfig(key, config) const clearConfig = () => _clearConfig(key) - return { name: key, config, setConfig, clearConfig, enabled, sendPayment } + return { name: key, config, setConfig, clearConfig, enabled, sendPayment: p.sendPayment.bind(p) } } From c9e7ba0b43fe7bd6fc24fe92edc12f5c4107611f Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 18 Jan 2024 04:15:25 +0100 Subject: [PATCH 10/75] Be optimistic regarding WebLN zaps This wraps the WebLN payment promise with Apollo cache updates. We will be optimistics and assume that the payment will succeed and update the cache accordingly. When we notice that the payment failed, we undo this update. --- components/invoice.js | 38 +++++++++++++++++++++++++--------- components/item-act.js | 47 +++++++++++++++++++++--------------------- 2 files changed, 51 insertions(+), 34 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index 6342a34e7..33b8a1000 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -181,7 +181,9 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { const client = useApolloClient() const pollInvoice = (id) => client.query({ query: INVOICE, variables: { id } }) - const onSubmitWrapper = useCallback(async ({ cost, ...formValues }, ...submitArgs) => { + const onSubmitWrapper = useCallback(async ( + { cost, ...formValues }, + { variables, optimisticResponse, update, ...apolloArgs }) => { // some actions require a session if (!me && options.requireSession) { throw new Error('you must be logged in') @@ -194,7 +196,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, + { variables, optimisticsResponse: insufficientFunds ? null : optimisticResponse, ...apolloArgs }) } catch (error) { if (!payOrLoginError(error) || !cost) { // can't handle error here - bail @@ -211,9 +215,19 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { const inv = data.createInvoice // wait until invoice is paid or modal is closed - const modalClose = await waitForPayment(inv, showModal, provider, pollInvoice) + const modalClose = await waitForPayment({ + invoice: inv, + showModal, + provider, + pollInvoice, + updateCache: () => update?.(client.cache, { data: optimisticResponse }), + undoUpdate: () => update?.(client.cache, { data: optimisticResponse }, true) + }) - 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 + { variables, update: null }) // first retry try { const ret = await retry() @@ -249,14 +263,16 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { return onSubmitWrapper } -const waitForPayment = async (inv, showModal, provider, pollInvoice) => { +const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, updateCache, undoUpdate }) => { try { // try WebLN provider first return await new Promise((resolve, reject) => { + // be optimistic and pretend zap was already successful for consistent zapping UX + updateCache?.() // 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(inv.bolt11) + provider.sendPayment(invoice.bolt11) // WebLN payment will never resolve here for HODL invoices // since they only get resolved after settlement which can't happen here .then(resolve) @@ -266,13 +282,13 @@ const waitForPayment = async (inv, showModal, provider, pollInvoice) => { }) const interval = setInterval(async () => { try { - const { data, error } = await pollInvoice(inv.id) + const { data, error } = await pollInvoice(invoice.id) if (error) { clearInterval(interval) return reject(error) } - const { invoice } = data - if (invoice.isHeld && invoice.satsReceived) { + const { invoice: inv } = data + if (inv.isHeld && inv.satsReceived) { clearInterval(interval) resolve() } @@ -284,6 +300,8 @@ const waitForPayment = async (inv, showModal, provider, pollInvoice) => { }) } catch (err) { console.error('WebLN payment failed:', err) + // undo attempt to make zapping UX consistent + undoUpdate?.() } // QR code as fallback @@ -291,7 +309,7 @@ const waitForPayment = async (inv, showModal, provider, pollInvoice) => { showModal(onClose => { return ( resolve(onClose)} /> ) diff --git a/components/item-act.js b/components/item-act.js index 6a9f8365d..977ce9d53 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -106,15 +106,15 @@ export default function ItemAct ({ onClose, itemId, down, children }) { export function useAct ({ onUpdate } = {}) { const me = useMe() - const update = useCallback((cache, args) => { - const { data: { act: { id, sats, path, act } } } = args + const update = useCallback((cache, args, undo) => { + const { data: { act: { id, sats, path, act, tip } } } = args cache.modify({ id: `Item:${id}`, fields: { sats (existingSats = 0) { if (act === 'TIP') { - return existingSats + sats + return existingSats + (undo ? -tip : sats) } return existingSats @@ -122,7 +122,7 @@ export function useAct ({ onUpdate } = {}) { meSats: me ? (existingSats = 0) => { if (act === 'TIP') { - return existingSats + sats + return existingSats + (undo ? -tip : sats) } return existingSats @@ -131,7 +131,7 @@ export function useAct ({ onUpdate } = {}) { meDontLikeSats: me ? (existingSats = 0) => { if (act === 'DONT_LIKE_THIS') { - return existingSats + sats + return existingSats + (undo ? -tip : sats) } return existingSats @@ -148,7 +148,7 @@ export function useAct ({ onUpdate } = {}) { id: `Item:${aId}`, fields: { commentSats (existingCommentSats = 0) { - return existingCommentSats + sats + return existingCommentSats + (undo ? -tip : sats) } } }) @@ -172,8 +172,8 @@ export function useAct ({ onUpdate } = {}) { } export function useZap () { - const update = useCallback((cache, args) => { - const { data: { act: { id, sats, path } } } = args + const update = useCallback((cache, args, undo) => { + const { data: { act: { id, sats, path, tip } } } = args // determine how much we increased existing sats by by checking the // difference between result sats and meSats @@ -191,12 +191,12 @@ export function useZap () { const satsDelta = sats - item.meSats - if (satsDelta > 0) { + if (satsDelta >= 0) { cache.modify({ id: `Item:${id}`, fields: { sats (existingSats = 0) { - return existingSats + satsDelta + return existingSats + (undo ? -tip : satsDelta) }, meSats: () => { return sats @@ -211,7 +211,7 @@ export function useZap () { id: `Item:${aId}`, fields: { commentSats (existingCommentSats = 0) { - return existingCommentSats + satsDelta + return existingCommentSats + (undo ? -tip : satsDelta) } } }) @@ -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,21 @@ 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 - } - } - }) + await zap({ variables, optimisticResponse: insufficientFunds ? null : optimisticResponse }) } catch (error) { if (payOrLoginError(error)) { // call non-idempotent version const amount = sats - meSats + optimisticResponse.act.tip = amount try { - await showInvoiceModal({ amount }, { variables: { ...variables, sats: amount } }) + await invoiceableAct({ amount }, { + variables: { ...variables, sats: amount }, + optimisticResponse, + update + }) } catch (error) {} return } From 8f0e9748aef17bf888399801c8d47ba5163d07b1 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 18 Jan 2024 05:07:20 +0100 Subject: [PATCH 11/75] Bold strike on WebLN zap If lightning strike animation is disabled, toaster will be used. --- components/invoice.js | 11 ++++++----- components/item-act.js | 6 ++++-- components/lightning.js | 10 +++++----- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index 33b8a1000..9276abbf6 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -215,7 +215,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { const inv = data.createInvoice // wait until invoice is paid or modal is closed - const modalClose = await waitForPayment({ + const payment = await waitForPayment({ invoice: inv, showModal, provider, @@ -223,11 +223,12 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { updateCache: () => update?.(client.cache, { data: optimisticResponse }), undoUpdate: () => update?.(client.cache, { data: optimisticResponse }, true) }) + const { webLN, modalClose } = payment const retry = () => onSubmit( { hash: inv.hash, hmac: inv.hmac, ...formValues }, // unset update function since we already ran an cache update - { variables, update: null }) + { variables, update: null }, webLN) // first retry try { const ret = await retry() @@ -275,7 +276,7 @@ const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, updat provider.sendPayment(invoice.bolt11) // WebLN payment will never resolve here for HODL invoices // since they only get resolved after settlement which can't happen here - .then(resolve) + .then(() => resolve({ webLN: true })) .catch(err => { clearInterval(interval) reject(err) @@ -290,7 +291,7 @@ const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, updat const { invoice: inv } = data if (inv.isHeld && inv.satsReceived) { clearInterval(interval) - resolve() + resolve({ webLN: true }) } } catch (err) { clearInterval(interval) @@ -310,7 +311,7 @@ const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, updat return ( resolve(onClose)} + onPayment={() => resolve({ modalClose: onClose })} /> ) }, { keepOpen: true, onClose: reject }) diff --git a/components/item-act.js b/components/item-act.js index 977ce9d53..f6a1d8366 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -235,9 +235,11 @@ export function useZap () { const [act] = useAct() const invoiceableAct = useInvoiceModal( - async ({ hash, hmac }, { variables, ...apolloArgs }) => { + async ({ hash, hmac }, { variables, ...apolloArgs }, webLN) => { await act({ variables: { ...variables, hash, hmac }, ...apolloArgs }) - strike() + if (!strike({ webLN })) { + toaster.success('WebLN zap successful') + } }, [act, strike]) return useCallback(async ({ item, me }) => { diff --git a/components/lightning.js b/components/lightning.js index 948abf56e..f875ef14e 100644 --- a/components/lightning.js +++ b/components/lightning.js @@ -12,12 +12,12 @@ export class LightningProvider extends React.Component { * Strike lightning on the screen, if the user has the setting enabled * @returns boolean indicating whether the strike actually happened, based on user preferences */ - strike = () => { + strike = ({ webLN } = {}) => { const should = window.localStorage.getItem('lnAnimate') || 'yes' if (should === 'yes') { this.setState(state => { return { - bolts: [...state.bolts, this.unstrike(state.bolts.length)} />] + bolts: [...state.bolts, this.unstrike(state.bolts.length)} />] } }) return true @@ -49,7 +49,7 @@ export function useLightning () { return useContext(LightningContext) } -export function Lightning ({ onDone }) { +export function Lightning ({ onDone, webLN }) { const canvasRef = useRef(null) useEffect(() => { @@ -67,7 +67,8 @@ export function Lightning ({ onDone }) { speed: 100, spread: 30, branches: 20, - onDone + onDone, + lineWidth: webLN ? 7 : 3 }) canvas.bolt.draw() }, []) @@ -84,7 +85,6 @@ function Bolt (ctx, options) { spread: 50, branches: 10, maxBranches: 10, - lineWidth: 3, ...options } this.point = [this.options.startPoint[0], this.options.startPoint[1]] From e93b9cb47e19fecce85835c490ca606beee2d609 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 18 Jan 2024 05:37:45 +0100 Subject: [PATCH 12/75] Rename undo variable to amount --- components/item-act.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/components/item-act.js b/components/item-act.js index f6a1d8366..5a14c4112 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -107,14 +107,14 @@ export function useAct ({ onUpdate } = {}) { const me = useMe() const update = useCallback((cache, args, undo) => { - const { data: { act: { id, sats, path, act, tip } } } = args + const { data: { act: { id, sats, path, act, amount } } } = args cache.modify({ id: `Item:${id}`, fields: { sats (existingSats = 0) { if (act === 'TIP') { - return existingSats + (undo ? -tip : sats) + return existingSats + (undo ? -amount : sats) } return existingSats @@ -122,7 +122,7 @@ export function useAct ({ onUpdate } = {}) { meSats: me ? (existingSats = 0) => { if (act === 'TIP') { - return existingSats + (undo ? -tip : sats) + return existingSats + (undo ? -amount : sats) } return existingSats @@ -131,7 +131,7 @@ export function useAct ({ onUpdate } = {}) { meDontLikeSats: me ? (existingSats = 0) => { if (act === 'DONT_LIKE_THIS') { - return existingSats + (undo ? -tip : sats) + return existingSats + (undo ? -amount : sats) } return existingSats @@ -148,7 +148,7 @@ export function useAct ({ onUpdate } = {}) { id: `Item:${aId}`, fields: { commentSats (existingCommentSats = 0) { - return existingCommentSats + (undo ? -tip : sats) + return existingCommentSats + (undo ? -amount : sats) } } }) @@ -173,7 +173,7 @@ export function useAct ({ onUpdate } = {}) { export function useZap () { const update = useCallback((cache, args, undo) => { - const { data: { act: { id, sats, path, tip } } } = args + const { data: { act: { id, sats, path, amount } } } = args // determine how much we increased existing sats by by checking the // difference between result sats and meSats @@ -196,7 +196,7 @@ export function useZap () { id: `Item:${id}`, fields: { sats (existingSats = 0) { - return existingSats + (undo ? -tip : satsDelta) + return existingSats + (undo ? -amount : satsDelta) }, meSats: () => { return sats @@ -211,7 +211,7 @@ export function useZap () { id: `Item:${aId}`, fields: { commentSats (existingCommentSats = 0) { - return existingCommentSats + (undo ? -tip : satsDelta) + return existingCommentSats + (undo ? -amount : satsDelta) } } }) @@ -264,7 +264,7 @@ export function useZap () { if (payOrLoginError(error)) { // call non-idempotent version const amount = sats - meSats - optimisticResponse.act.tip = amount + optimisticResponse.act.amount = amount try { await invoiceableAct({ amount }, { variables: { ...variables, sats: amount }, From c63b4a7f9f8ad46c4a336afa32ba9607d2ff9bf4 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 18 Jan 2024 05:37:53 +0100 Subject: [PATCH 13/75] Fix zap undo --- components/item-act.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/item-act.js b/components/item-act.js index 5a14c4112..21a9d1f3a 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -199,7 +199,7 @@ export function useZap () { return existingSats + (undo ? -amount : satsDelta) }, meSats: () => { - return sats + return undo ? sats - amount : sats } } }) From 0f7f1a54111be197a91dfa4431b16ef7595d8b61 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Fri, 19 Jan 2024 01:14:26 +0100 Subject: [PATCH 14/75] Add NWC card --- components/webln/index.js | 8 ++-- components/webln/nwc.js | 33 ++++++++++++++++ lib/validate.js | 4 ++ pages/settings/wallets/index.js | 2 + pages/settings/wallets/nwc.js | 70 +++++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 components/webln/nwc.js create mode 100644 pages/settings/wallets/nwc.js diff --git a/components/webln/index.js b/components/webln/index.js index ab1d07e4b..94249f08e 100644 --- a/components/webln/index.js +++ b/components/webln/index.js @@ -1,9 +1,10 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'react' import LNbitsProvider from './lnbits' +import NWCProvider from './nwc' const WebLNContext = createContext({}) -const _providers = { lnbits: LNbitsProvider } +const _providers = { lnbits: LNbitsProvider, nwc: NWCProvider } export function WebLNProvider ({ children }) { const [providers, setProviders] = useState(_providers) @@ -17,12 +18,13 @@ export function WebLNProvider ({ children }) { [key]: { config, enabled: pkey.enabled, - sendPayment: pkey.sendPayment.bind(pkey) + sendPayment: pkey.sendPayment?.bind(pkey) } })) } // init providers on client initProvider('lnbits') + initProvider('nwc') // TODO support more WebLN providers }, []) @@ -58,5 +60,5 @@ export function useWebLN (key) { const setConfig = (config) => _setConfig(key, config) const clearConfig = () => _clearConfig(key) - return { name: key, config, setConfig, clearConfig, enabled, sendPayment: p.sendPayment.bind(p) } + return { name: key, config, setConfig, clearConfig, enabled, sendPayment: p.sendPayment?.bind(p) } } diff --git a/components/webln/nwc.js b/components/webln/nwc.js new file mode 100644 index 000000000..f702505ac --- /dev/null +++ b/components/webln/nwc.js @@ -0,0 +1,33 @@ +// https://github.com/getAlby/js-sdk/blob/master/src/webln/NostrWeblnProvider.ts + +export default { + storageKey: 'webln:provider:nwc', + _nwcUrl: null, + enabled: false, + async load () { + const config = window.localStorage.getItem(this.storageKey) + if (!config) return null + const configJSON = JSON.parse(config) + this._nwcUrl = configJSON.nwcUrl + try { + await this._updateEnabled() + } catch (err) { + console.error(err) + } + return configJSON + }, + async save (config) { + this._nwcUrl = config.nwcUrl + await this._updateEnabled() + window.localStorage.setItem(this.storageKey, JSON.stringify(config)) + }, + clear () { + window.localStorage.removeItem(this.storageKey) + this._nwcUrl = null + this.enabled = false + }, + async _updateEnabled () { + // TODO: use proper check, for example relay connection + this.enabled = !!this._nwcUrl + } +} diff --git a/lib/validate.js b/lib/validate.js index 4f2166e39..69f8f8830 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -426,6 +426,10 @@ export const lnbitsSchema = object({ 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/pages/settings/wallets/index.js b/pages/settings/wallets/index.js index ce8f9f6a0..a03b5589b 100644 --- a/pages/settings/wallets/index.js +++ b/pages/settings/wallets/index.js @@ -4,6 +4,7 @@ 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 }) @@ -16,6 +17,7 @@ export default function Wallet () {
+ diff --git a/pages/settings/wallets/nwc.js b/pages/settings/wallets/nwc.js new file mode 100644 index 000000000..f89fe462f --- /dev/null +++ b/pages/settings/wallets/nwc.js @@ -0,0 +1,70 @@ +import { getGetServerSideProps } from '../../../api/ssrApollo' +import { Form, Input } 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 { useWebLN } from '../../../components/webln' + +export const getServerSideProps = getGetServerSideProps({ authRequired: true }) + +export default function NWC () { + const { config, setConfig, clearConfig, enabled } = useWebLN('nwc') + const toaster = useToast() + const router = useRouter() + + return ( + +

nwc

+
use Nostr Wallet Connect for zapping
+ { + try { + await setConfig(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 } = useWebLN('nwc') + return ( + + ) +} From d659ff243460942f5bc9206ed0b76b945dfeae94 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 20 Jan 2024 03:35:01 +0100 Subject: [PATCH 15/75] Attempt to check NWC connection using info event --- components/webln/nwc.js | 85 ++++++++++++++++++++++++++++++- package-lock.json | 108 ++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 192 insertions(+), 2 deletions(-) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index f702505ac..0fc90a852 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -1,14 +1,40 @@ // https://github.com/getAlby/js-sdk/blob/master/src/webln/NostrWeblnProvider.ts +import { Relay, nip04 } from 'nostr-tools' + +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 options = {} + options.walletPubkey = url.host + const secret = url.searchParams.get('secret') + const relayUrl = url.searchParams.get('relay') + if (secret) { + options.secret = secret + } + if (relayUrl) { + options.relayUrl = relayUrl + } + return options +} + export default { storageKey: 'webln:provider:nwc', _nwcUrl: null, + _walletPubkey: null, + _relayUrl: null, + _secret: null, enabled: false, async load () { const config = window.localStorage.getItem(this.storageKey) if (!config) return null const configJSON = JSON.parse(config) this._nwcUrl = configJSON.nwcUrl + this._walletPubkey = configJSON.walletPubkey + this._relayUrl = configJSON.relayUrl + this._secret = configJSON.secret try { await this._updateEnabled() } catch (err) { @@ -18,6 +44,10 @@ export default { }, async save (config) { this._nwcUrl = config.nwcUrl + const params = parseWalletConnectUrl(config.nwcUrl) + this._walletPubkey = config.walletPubkey = params.walletPubkey + this._relayUrl = config.relayUrl = params.relayUrl + this._secret = config.secret = params.secret await this._updateEnabled() window.localStorage.setItem(this.storageKey, JSON.stringify(config)) }, @@ -27,7 +57,58 @@ export default { this.enabled = false }, async _updateEnabled () { - // TODO: use proper check, for example relay connection - this.enabled = !!this._nwcUrl + try { + // FIXME: this doesn't work since relay responds with EOSE immediately + // await this.getInfo() + this.enabled = !!this._nwcUrl && !!this._walletPubkey && !!this._relayUrl && !!this._secret + } catch (err) { + console.error(err) + this.enabled = false + } + }, + async encrypt (pubkey, content) { + if (!this.secret) { + throw new Error('Missing secret') + } + const encrypted = await nip04.encrypt(this._secret, pubkey, content) + return encrypted + }, + async decrypt (pubkey, content) { + if (!this.secret) { + throw new Error('Missing secret') + } + const decrypted = await nip04.decrypt(this._secret, pubkey, content) + return decrypted + }, + // WebLN compatible response + // TODO: use NIP-47 get_info call + async getInfo () { + const relayUrl = this._relayUrl + const walletPubkey = this._walletPubkey + return new Promise(function (resolve, reject) { + (async function () { + const timeout = 5000 + const timer = setTimeout(() => reject(new Error('timeout')), timeout) + const relay = await Relay.connect(relayUrl) + const sub = relay.subscribe([ + { + kinds: [13194], + authors: [walletPubkey] + } + ], { + onevent (event) { + clearTimeout(timer) + sub.close() + // TODO: verify event + resolve(event) + }, + oneose () { + clearTimeout(timer) + sub.close() + reject(new Error('EOSE')) + } + }) + })().catch(reject) + }) } } 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", From 19031bd1597993d26ea2ff2686f31b7b2b7d7f60 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 20 Jan 2024 03:56:53 +0100 Subject: [PATCH 16/75] Fix NaN on zap Third argument of update is reserved for context --- components/invoice.js | 2 +- components/item-act.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index 9276abbf6..2cd37910c 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -221,7 +221,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { provider, pollInvoice, updateCache: () => update?.(client.cache, { data: optimisticResponse }), - undoUpdate: () => update?.(client.cache, { data: optimisticResponse }, true) + undoUpdate: () => update?.(client.cache, { data: { ...optimisticResponse, undo: true } }) }) const { webLN, modalClose } = payment diff --git a/components/item-act.js b/components/item-act.js index 21a9d1f3a..e86cc427e 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -172,8 +172,8 @@ export function useAct ({ onUpdate } = {}) { } export function useZap () { - const update = useCallback((cache, args, undo) => { - const { data: { act: { id, sats, path, amount } } } = args + const update = useCallback((cache, args) => { + const { data: { act: { id, sats, path, amount, undo } } } = args // determine how much we increased existing sats by by checking the // difference between result sats and meSats From b7039f98685697fbd9ba2d7c4d23eeb4a0ef99ea Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 20 Jan 2024 05:55:16 +0100 Subject: [PATCH 17/75] Fix TypeError in catch of QR code --- components/qr.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/qr.js b/components/qr.js index 4209c9737..422b9c74f 100644 --- a/components/qr.js +++ b/components/qr.js @@ -17,8 +17,8 @@ export default function Qr ({ asIs, value, webLn, statusVariant, description, st try { await provider.sendPayment(value) } catch (e) { - console.log(e.message) - toaster.danger(`${provider.name}: ${e.message}`) + console.log(e?.message) + toaster.danger(`${provider.name}: ${e?.message}`) } } } From 1b4cf78c3b7ba91a14573d228c6b8cb372005894 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 20 Jan 2024 06:05:00 +0100 Subject: [PATCH 18/75] Add basic NWC payments --- components/webln/index.js | 4 +-- components/webln/nwc.js | 54 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/components/webln/index.js b/components/webln/index.js index 94249f08e..825aaf50c 100644 --- a/components/webln/index.js +++ b/components/webln/index.js @@ -52,7 +52,7 @@ export function useWebLN (key) { if (!key) { // TODO pick preferred enabled WebLN provider here - key = 'lnbits' + key = 'nwc' } const p = provider[key] @@ -60,5 +60,5 @@ export function useWebLN (key) { const setConfig = (config) => _setConfig(key, config) const clearConfig = () => _clearConfig(key) - return { name: key, config, setConfig, clearConfig, enabled, sendPayment: p.sendPayment?.bind(p) } + return { name: key, config, setConfig, clearConfig, enabled, sendPayment: p.sendPayment.bind(p) } } diff --git a/components/webln/nwc.js b/components/webln/nwc.js index 0fc90a852..fffb21ced 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -1,6 +1,6 @@ // https://github.com/getAlby/js-sdk/blob/master/src/webln/NostrWeblnProvider.ts -import { Relay, nip04 } from 'nostr-tools' +import { Relay, finalizeEvent, nip04 } from 'nostr-tools' function parseWalletConnectUrl (walletConnectUrl) { walletConnectUrl = walletConnectUrl @@ -80,6 +80,58 @@ export default { const decrypted = await nip04.decrypt(this._secret, pubkey, content) return decrypted }, + async sendPayment (bolt11) { + const relayUrl = this._relayUrl + const walletPubkey = this._walletPubkey + const secret = this._secret + return new Promise(function (resolve, reject) { + (async function () { + // need big timeout since NWC is async (user needs to confirm payment in wallet) + const timeout = 60000 + let timer + const resetTimer = () => { + clearTimeout(timer) + timer = setTimeout(() => reject(new Error('timeout')), timeout) + } + resetTimer() + const relay = await Relay.connect(relayUrl) + const payload = { + method: 'pay_invoice', + params: { invoice: bolt11 } + } + const content = await nip04.encrypt(secret, walletPubkey, JSON.stringify(payload)) + const request = finalizeEvent({ + pubkey: walletPubkey, + kind: 23194, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content + }, secret) + await relay.publish(request) + const sub = relay.subscribe([ + { + kinds: [23195], + authors: [walletPubkey], + '#e': [request.id] + } + ], { + 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(content.result.preimage) + } catch (err) { + return reject(err) + } finally { + clearTimeout(timer) + sub.close() + } + } + }) + })().catch(reject) + }) + }, // WebLN compatible response // TODO: use NIP-47 get_info call async getInfo () { From 08d0dca2f05ef6fbea86791488c2256d960220aa Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 20 Jan 2024 22:23:15 +0100 Subject: [PATCH 19/75] Wrap LNbits getInfo with try/catch --- components/webln/lnbits.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/components/webln/lnbits.js b/components/webln/lnbits.js index c7cf9c052..06bc7d6a7 100644 --- a/components/webln/lnbits.js +++ b/components/webln/lnbits.js @@ -9,11 +9,7 @@ export default { const configJSON = JSON.parse(config) this._url = configJSON.url this._adminKey = configJSON.adminKey - try { - await this._updateEnabled() - } catch (err) { - console.error(err) - } + await this._updateEnabled() return configJSON }, async save (config) { @@ -107,7 +103,12 @@ export default { this.enabled = false return } - await this.getInfo() - this.enabled = true + try { + await this.getInfo() + this.enabled = true + } catch (err) { + console.error(err) + this.enabled = false + } } } From 0951adc92f3dfe1292ee437f292e1c1817c21e7e Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 20 Jan 2024 22:37:32 +0100 Subject: [PATCH 20/75] EOSE is enough to check NWC connection --- components/webln/nwc.js | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index fffb21ced..fa0ccf827 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -57,10 +57,13 @@ export default { this.enabled = false }, async _updateEnabled () { + if (!(this._nwcUrl && this._walletPubkey && this._relayUrl && this._secret)) { + this.enabled = false + return + } try { - // FIXME: this doesn't work since relay responds with EOSE immediately - // await this.getInfo() - this.enabled = !!this._nwcUrl && !!this._walletPubkey && !!this._relayUrl && !!this._secret + await this.getInfo() + this.enabled = true } catch (err) { console.error(err) this.enabled = false @@ -148,16 +151,12 @@ export default { authors: [walletPubkey] } ], { - onevent (event) { - clearTimeout(timer) - sub.close() - // TODO: verify event - resolve(event) - }, + // some relays like nostr.mutinywallet.com don't support NIP-47 info events + // so we simply check that we received EOSE oneose () { clearTimeout(timer) sub.close() - reject(new Error('EOSE')) + resolve() } }) })().catch(reject) From bfdbe4ea2b34cabb55e184c4609a49d859078779 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 21 Jan 2024 01:55:14 +0100 Subject: [PATCH 21/75] refactor: Wrap WebLN providers into own context I should have done this earlier --- components/invoice.js | 2 +- components/webln/index.js | 73 ++++--------- components/webln/lnbits.js | 176 ++++++++++++++++-------------- components/webln/nwc.js | 180 ++++++++++++++++--------------- pages/settings/wallets/lnbits.js | 12 +-- pages/settings/wallets/nwc.js | 10 +- 6 files changed, 223 insertions(+), 230 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index 2cd37910c..622fc7bdb 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -259,7 +259,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { ) }, { keepOpen: true, onClose: cancelAndReject }) }) - }, [onSubmit, createInvoice, !!me]) + }, [onSubmit, provider, createInvoice, !!me]) return onSubmitWrapper } diff --git a/components/webln/index.js b/components/webln/index.js index 825aaf50c..2825ea53e 100644 --- a/components/webln/index.js +++ b/components/webln/index.js @@ -1,64 +1,35 @@ -import { createContext, useCallback, useContext, useEffect, useState } from 'react' -import LNbitsProvider from './lnbits' -import NWCProvider from './nwc' +import { createContext, useContext } from 'react' +import { LNbitsProvider, useLNbits } from './lnbits' +import { NWCProvider, useNWC } from './nwc' const WebLNContext = createContext({}) -const _providers = { lnbits: LNbitsProvider, nwc: NWCProvider } +function RawWebLNProvider ({ children }) { + const lnbits = useLNbits() + const nwc = useNWC() -export function WebLNProvider ({ children }) { - const [providers, setProviders] = useState(_providers) - - useEffect(() => { - const initProvider = async key => { - const config = await _providers[key].load() - const pkey = _providers[key] - setProviders(p => ({ - ...p, - [key]: { - config, - enabled: pkey.enabled, - sendPayment: pkey.sendPayment?.bind(pkey) - } - })) - } - // init providers on client - initProvider('lnbits') - initProvider('nwc') - // TODO support more WebLN providers - }, []) - - const setConfig = useCallback(async (key, config) => { - await _providers[key].save(config) - const { enabled } = _providers[key] - setProviders(p => ({ ...p, [key]: { ...p[key], config, enabled } })) - }, [providers]) - - const clearConfig = useCallback(async (key) => { - await _providers[key].clear() - const { enabled } = _providers[key] - setProviders(p => ({ ...p, [key]: { ...p[key], config: null, enabled } })) - }, [providers]) + // TODO: switch between providers based on user preference + const provider = nwc return ( - + {children} ) } -export function useWebLN (key) { - const { provider, setConfig: _setConfig, clearConfig: _clearConfig } = useContext(WebLNContext) - - if (!key) { - // TODO pick preferred enabled WebLN provider here - key = 'nwc' - } - - const p = provider[key] - const { config, enabled } = p - const setConfig = (config) => _setConfig(key, config) - const clearConfig = () => _clearConfig(key) +export function WebLNProvider ({ children }) { + return ( + + + + {children} + + + + ) +} - return { name: key, config, setConfig, clearConfig, enabled, sendPayment: p.sendPayment.bind(p) } +export function useWebLN () { + return useContext(WebLNContext) } diff --git a/components/webln/lnbits.js b/components/webln/lnbits.js index 06bc7d6a7..f0461668c 100644 --- a/components/webln/lnbits.js +++ b/components/webln/lnbits.js @@ -1,35 +1,45 @@ -export default { - storageKey: 'webln:provider:lnbits', - _url: null, - _adminKey: null, - enabled: false, - async load () { - const config = window.localStorage.getItem(this.storageKey) - if (!config) return null - const configJSON = JSON.parse(config) - this._url = configJSON.url - this._adminKey = configJSON.adminKey - await this._updateEnabled() - return configJSON - }, - async save (config) { - this._url = config.url - this._adminKey = config.adminKey - await this._updateEnabled() - // 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(this.storageKey, JSON.stringify(config)) - }, - clear () { - window.localStorage.removeItem(this.storageKey) - this._url = null - this._adminKey = null - this.enabled = false - }, - async getInfo () { - // https://github.com/getAlby/bitcoin-connect/blob/v3.2.0-alpha/src/connectors/LnbitsConnector.ts - const response = await this._request( +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() + +export function LNbitsProvider ({ children }) { + const [url, setUrl] = useState() + const [adminKey, setAdminKey] = useState() + const [enabled, setEnabled] = useState() + + const storageKey = 'webln:provider:lnbits' + + const request = useCallback(async (method, path, args) => { + let body = null + const query = '' + const headers = new Headers() + headers.append('Accept', 'application/json') + headers.append('Content-Type', 'application/json') + headers.append('X-Api-Key', adminKey) + + if (method === 'POST') { + body = JSON.stringify(args) + } else if (args !== undefined) { + throw new Error('TODO: support args in GET') + // query = ... + } + const url_ = url.replace(/\/+$/, '') + const res = await fetch(url_ + path + query, { + method, + headers, + body + }) + if (!res.ok) { + const errBody = await res.json() + throw new Error(errBody.detail) + } + return (await res.json()) + }, [url, adminKey]) + + const getInfo = useCallback(async () => { + const response = await request( 'GET', '/api/v1/wallet' ) @@ -47,9 +57,10 @@ export default { version: '1.0', supports: ['lightning'] } - }, - async sendPayment (bolt11) { - const response = await this._request( + }, [request]) + + const sendPayment = useCallback(async (bolt11) => { + const response = await request( 'POST', '/api/v1/payments', { @@ -57,58 +68,65 @@ export default { out: true } ) - - const checkResponse = await this._request( + const checkResponse = await request( 'GET', `/api/v1/payments/${response.payment_hash}` ) - if (!checkResponse.preimage) { throw new Error('No preimage') } return { preimage: checkResponse.preimage } - }, - async _request (method, path, args) { - if (!(this._url && this._adminKey)) throw new Error('provider not configured') - // https://github.com/getAlby/bitcoin-connect/blob/v3.2.0-alpha/src/connectors/LnbitsConnector.ts - let body = null - const query = '' - const headers = new Headers() - headers.append('Accept', 'application/json') - headers.append('Content-Type', 'application/json') - headers.append('X-Api-Key', this._adminKey) + }, [request]) - if (method === 'POST') { - body = JSON.stringify(args) - } else if (args !== undefined) { - throw new Error('TODO: support args in GET') - // query = ... - } - const url = this._url.replace(/\/+$/, '') - const res = await fetch(url + path + query, { - method, - headers, - body - }) - if (!res.ok) { - const errBody = await res.json() - throw new Error(errBody.detail) - } - return (await res.json()) - }, - async _updateEnabled () { - if (!(this._url && this._adminKey)) { - this.enabled = false - return - } - try { - await this.getInfo() - this.enabled = true - } catch (err) { - console.error(err) - this.enabled = false - } - } + const loadConfig = useCallback(() => { + const config = window.localStorage.getItem(storageKey) + if (!config) return null + const configJSON = JSON.parse(config) + setUrl(configJSON.url) + setAdminKey(configJSON.adminKey) + }, []) + + const saveConfig = useCallback(async (config) => { + setUrl(config.url) + setAdminKey(config.adminKey) + // 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)) + }, []) + + const clearConfig = useCallback(() => { + window.localStorage.removeItem(storageKey) + setUrl(null) + setAdminKey(null) + setEnabled(false) + }, []) + + useEffect(() => { + (async function () { + if (!(url && adminKey)) return setEnabled(false) + try { + await getInfo() + setEnabled(true) + } catch (err) { + console.error(err) + setEnabled(false) + } + })() + }, [url, adminKey, getInfo]) + + useEffect(loadConfig, []) + + const value = { url, adminKey, saveConfig, clearConfig, enabled, sendPayment } + return ( + + {children} + + ) +} + +export function useLNbits () { + return useContext(LNbitsContext) } diff --git a/components/webln/nwc.js b/components/webln/nwc.js index fa0ccf827..e1ce6382d 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -1,92 +1,39 @@ // 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' -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 options = {} - options.walletPubkey = url.host - const secret = url.searchParams.get('secret') - const relayUrl = url.searchParams.get('relay') - if (secret) { - options.secret = secret - } - if (relayUrl) { - options.relayUrl = relayUrl - } - return options -} +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() -export default { - storageKey: 'webln:provider:nwc', - _nwcUrl: null, - _walletPubkey: null, - _relayUrl: null, - _secret: null, - enabled: false, - async load () { - const config = window.localStorage.getItem(this.storageKey) + const storageKey = 'webln:provider:nwc' + + const loadConfig = useCallback(() => { + const config = window.localStorage.getItem(storageKey) if (!config) return null const configJSON = JSON.parse(config) - this._nwcUrl = configJSON.nwcUrl - this._walletPubkey = configJSON.walletPubkey - this._relayUrl = configJSON.relayUrl - this._secret = configJSON.secret - try { - await this._updateEnabled() - } catch (err) { - console.error(err) - } - return configJSON - }, - async save (config) { - this._nwcUrl = config.nwcUrl - const params = parseWalletConnectUrl(config.nwcUrl) - this._walletPubkey = config.walletPubkey = params.walletPubkey - this._relayUrl = config.relayUrl = params.relayUrl - this._secret = config.secret = params.secret - await this._updateEnabled() - window.localStorage.setItem(this.storageKey, JSON.stringify(config)) - }, - clear () { - window.localStorage.removeItem(this.storageKey) - this._nwcUrl = null - this.enabled = false - }, - async _updateEnabled () { - if (!(this._nwcUrl && this._walletPubkey && this._relayUrl && this._secret)) { - this.enabled = false - return - } - try { - await this.getInfo() - this.enabled = true - } catch (err) { - console.error(err) - this.enabled = false - } - }, - async encrypt (pubkey, content) { - if (!this.secret) { - throw new Error('Missing secret') - } - const encrypted = await nip04.encrypt(this._secret, pubkey, content) - return encrypted - }, - async decrypt (pubkey, content) { - if (!this.secret) { - throw new Error('Missing secret') - } - const decrypted = await nip04.decrypt(this._secret, pubkey, content) - return decrypted - }, - async sendPayment (bolt11) { - const relayUrl = this._relayUrl - const walletPubkey = this._walletPubkey - const secret = this._secret + setNwcUrl(configJSON.nwcUrl) + }, []) + + const saveConfig = useCallback(async (config) => { + setNwcUrl(config.nwcUrl) + // XXX Even though NWC allows to configure budget, + // this is definitely not ideal from a security perspective. + window.localStorage.setItem(storageKey, JSON.stringify(config)) + }, []) + + const clearConfig = useCallback(() => { + window.localStorage.removeItem(storageKey) + setNwcUrl(null) + }, []) + + const sendPayment = useCallback((bolt11) => { return new Promise(function (resolve, reject) { (async function () { // need big timeout since NWC is async (user needs to confirm payment in wallet) @@ -134,12 +81,9 @@ export default { }) })().catch(reject) }) - }, - // WebLN compatible response - // TODO: use NIP-47 get_info call - async getInfo () { - const relayUrl = this._relayUrl - const walletPubkey = this._walletPubkey + }, [relayUrl, walletPubkey, secret]) + + const getInfo = useCallback(() => { return new Promise(function (resolve, reject) { (async function () { const timeout = 5000 @@ -161,5 +105,65 @@ export default { }) })().catch(reject) }) + }, [relayUrl, walletPubkey]) + + useEffect(() => { + // update enabled + (async function () { + if (!(relayUrl && walletPubkey && secret)) return setEnabled(false) + try { + await getInfo() + setEnabled(true) + } catch (err) { + console.error(err) + setEnabled(false) + } + })() + }, [relayUrl, walletPubkey, secret, getInfo]) + + useEffect(() => { + // parse nwc URL on updates + // and sync with other state variables + if (!nwcUrl) { + setRelayUrl(null) + setWalletPubkey(null) + setSecret(null) + return + } + const params = parseWalletConnectUrl(nwcUrl) + setRelayUrl(params.relayUrl) + setWalletPubkey(params.walletPubkey) + setSecret(params.secret) + }, [nwcUrl]) + + useEffect(loadConfig, []) + + const value = { nwcUrl, relayUrl, walletPubkey, secret, saveConfig, clearConfig, enabled, sendPayment } + return ( + + {children} + + ) +} + +export function useNWC () { + return useContext(NWCContext) +} + +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 options = {} + options.walletPubkey = url.host + const secret = url.searchParams.get('secret') + const relayUrl = url.searchParams.get('relay') + if (secret) { + options.secret = secret + } + if (relayUrl) { + options.relayUrl = relayUrl } + return options } diff --git a/pages/settings/wallets/lnbits.js b/pages/settings/wallets/lnbits.js index bbaf4d23f..ccfdbe08b 100644 --- a/pages/settings/wallets/lnbits.js +++ b/pages/settings/wallets/lnbits.js @@ -5,12 +5,12 @@ import { WalletButtonBar, WalletCard } from '../../../components/wallet-card' import { lnbitsSchema } from '../../../lib/validate' import { useToast } from '../../../components/toast' import { useRouter } from 'next/router' -import { useWebLN } from '../../../components/webln' +import { useLNbits } from '../../../components/webln/lnbits' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) export default function LNbits () { - const { config, setConfig, clearConfig, enabled } = useWebLN('lnbits') + const { url, adminKey, saveConfig, clearConfig, enabled } = useLNbits() const toaster = useToast() const router = useRouter() @@ -20,13 +20,13 @@ export default function LNbits () {
use lnbits for zapping
{ try { - await setConfig(values) + await saveConfig(values) toaster.success('saved settings') router.push('/settings/wallets') } catch (err) { @@ -65,7 +65,7 @@ export default function LNbits () { } export function LNbitsCard () { - const { enabled } = useWebLN('lnbits') + const { enabled } = useLNbits() return ( use Nostr Wallet Connect for zapping { try { - await setConfig(values) + await saveConfig(values) toaster.success('saved settings') router.push('/settings/wallets') } catch (err) { @@ -58,7 +58,7 @@ export default function NWC () { } export function NWCCard () { - const { enabled } = useWebLN('nwc') + const { enabled } = useNWC() return ( Date: Mon, 22 Jan 2024 02:52:42 +0100 Subject: [PATCH 22/75] Show red indicator on error --- components/wallet-card.js | 2 +- components/webln/lnbits.js | 7 +++++-- components/webln/nwc.js | 5 ++++- styles/wallet.module.css | 7 +++++++ 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/components/wallet-card.js b/components/wallet-card.js index 50ce06df4..d5923c6b7 100644 --- a/components/wallet-card.js +++ b/components/wallet-card.js @@ -9,7 +9,7 @@ import { SubmitButton } from './form' export function WalletCard ({ title, badges, provider, enabled }) { return ( -
+
{title} diff --git a/components/webln/lnbits.js b/components/webln/lnbits.js index f0461668c..134edea50 100644 --- a/components/webln/lnbits.js +++ b/components/webln/lnbits.js @@ -101,12 +101,15 @@ export function LNbitsProvider ({ children }) { window.localStorage.removeItem(storageKey) setUrl(null) setAdminKey(null) - setEnabled(false) }, []) useEffect(() => { + // update enabled (async function () { - if (!(url && adminKey)) return setEnabled(false) + if (!(url && adminKey)) { + setEnabled(undefined) + return + } try { await getInfo() setEnabled(true) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index e1ce6382d..1dd9d22e3 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -110,7 +110,10 @@ export function NWCProvider ({ children }) { useEffect(() => { // update enabled (async function () { - if (!(relayUrl && walletPubkey && secret)) return setEnabled(false) + if (!(relayUrl && walletPubkey && secret)) { + setEnabled(undefined) + return + } try { await getInfo() setEnabled(true) 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; From 408f20a4c1df0507c9cd8a89791fe49a520c550d Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 22 Jan 2024 02:59:52 +0100 Subject: [PATCH 23/75] Fix useEffect return value --- components/webln/lnbits.js | 2 +- components/webln/nwc.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/webln/lnbits.js b/components/webln/lnbits.js index 134edea50..c48d42fbb 100644 --- a/components/webln/lnbits.js +++ b/components/webln/lnbits.js @@ -82,7 +82,7 @@ export function LNbitsProvider ({ children }) { const loadConfig = useCallback(() => { const config = window.localStorage.getItem(storageKey) - if (!config) return null + if (!config) return const configJSON = JSON.parse(config) setUrl(configJSON.url) setAdminKey(configJSON.adminKey) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index 1dd9d22e3..9ece3f3df 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -16,7 +16,7 @@ export function NWCProvider ({ children }) { const loadConfig = useCallback(() => { const config = window.localStorage.getItem(storageKey) - if (!config) return null + if (!config) return const configJSON = JSON.parse(config) setNwcUrl(configJSON.nwcUrl) }, []) From 2da2fc4690100495bf6b3cab9a6450884118c830 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 22 Jan 2024 04:53:21 +0100 Subject: [PATCH 24/75] Fix wrong usage of pubkey The event pubkey is derived from the secret. Doesn't make sense to manually set it. It's also the wrong pubkey: we're not the wallet service. --- components/webln/nwc.js | 1 - 1 file changed, 1 deletion(-) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index 9ece3f3df..b18c6b893 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -51,7 +51,6 @@ export function NWCProvider ({ children }) { } const content = await nip04.encrypt(secret, walletPubkey, JSON.stringify(payload)) const request = finalizeEvent({ - pubkey: walletPubkey, kind: 23194, created_at: Math.floor(Date.now() / 1000), tags: [], From 0b4684adf8027f43ca34751f9302519af6d4d323 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 22 Jan 2024 04:54:21 +0100 Subject: [PATCH 25/75] Use p tag in NWC request --- components/webln/nwc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index b18c6b893..43b557f44 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -53,7 +53,7 @@ export function NWCProvider ({ children }) { const request = finalizeEvent({ kind: 23194, created_at: Math.floor(Date.now() / 1000), - tags: [], + tags: [['p', walletPubkey]], content }, secret) await relay.publish(request) From b3f3f9604d14254b2bc1d415f43f744dbf04f3d1 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 22 Jan 2024 05:18:30 +0100 Subject: [PATCH 26/75] Add comment about required filter field --- components/webln/nwc.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index 43b557f44..bc2e097e4 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -60,6 +60,8 @@ export function NWCProvider ({ children }) { const sub = relay.subscribe([ { kinds: [23195], + // for some reason, 'authors' must be set in the filter else you will debug your code for hours. + // this doesn't seem to be documented in NIP-01 or NIP-47. authors: [walletPubkey], '#e': [request.id] } From 0c0129493f2f4c6a617da16e9d56efcf95815a5c Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 22 Jan 2024 05:22:08 +0100 Subject: [PATCH 27/75] Aesthetic changes to NWC sendPayment --- components/webln/nwc.js | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index bc2e097e4..f95da05d1 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -43,8 +43,9 @@ export function NWCProvider ({ children }) { clearTimeout(timer) timer = setTimeout(() => reject(new Error('timeout')), timeout) } - resetTimer() + const relay = await Relay.connect(relayUrl) + const payload = { method: 'pay_invoice', params: { invoice: bolt11 } @@ -57,15 +58,16 @@ export function NWCProvider ({ children }) { content }, secret) await relay.publish(request) - const sub = relay.subscribe([ - { - kinds: [23195], - // for some reason, 'authors' must be set in the filter else you will debug your code for hours. - // this doesn't seem to be documented in NIP-01 or NIP-47. - authors: [walletPubkey], - '#e': [request.id] - } - ], { + resetTimer() + + const filter = { + kinds: [23195], + // for some reason, 'authors' must be set in the filter else you will debug your code for hours. + // this doesn't seem to be documented in NIP-01 or NIP-47. + authors: [walletPubkey], + '#e': [request.id] + } + const sub = relay.subscribe([filter], { async onevent (response) { resetTimer() try { @@ -78,6 +80,9 @@ export function NWCProvider ({ children }) { clearTimeout(timer) sub.close() } + }, + onclose (reason) { + reject(new Error(reason)) } }) })().catch(reject) From e3d01b6d67307ce072a308d4aeb33ba5529ee675 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 22 Jan 2024 05:28:40 +0100 Subject: [PATCH 28/75] Add TODO about receipt verification --- components/webln/nwc.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index f95da05d1..edcd2862e 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -70,6 +70,8 @@ export function NWCProvider ({ children }) { const sub = relay.subscribe([filter], { async onevent (response) { resetTimer() + // TODO: check if we need verification here. does nostr-tools verify events? + // can we trust the NWC relay to respect our filters? try { const content = JSON.parse(await nip04.decrypt(secret, walletPubkey, response.content)) if (content.error) return reject(new Error(content.error.message)) From d11d79bf1c77af028d4a8d89b73a678ea34c8418 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 22 Jan 2024 05:34:46 +0100 Subject: [PATCH 29/75] Fix WebLN attempted again after error --- components/invoice.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index 622fc7bdb..658a66490 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -14,12 +14,15 @@ 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' @@ -119,7 +122,7 @@ const JITInvoice = ({ invoice: { id, hash, hmac, expiresAt }, onPayment, onCance return ( <> - + {retry ? ( <> From 335fc7628d97e8dec6f7528a37ab9f23c1542976 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 22 Jan 2024 05:46:25 +0100 Subject: [PATCH 30/75] Fix undefined name --- components/webln/lnbits.js | 3 ++- components/webln/nwc.js | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/components/webln/lnbits.js b/components/webln/lnbits.js index c48d42fbb..ac43d30df 100644 --- a/components/webln/lnbits.js +++ b/components/webln/lnbits.js @@ -9,6 +9,7 @@ export function LNbitsProvider ({ children }) { const [adminKey, setAdminKey] = useState() const [enabled, setEnabled] = useState() + const name = 'LNbits' const storageKey = 'webln:provider:lnbits' const request = useCallback(async (method, path, args) => { @@ -122,7 +123,7 @@ export function LNbitsProvider ({ children }) { useEffect(loadConfig, []) - const value = { url, adminKey, saveConfig, clearConfig, enabled, sendPayment } + const value = { name, url, adminKey, saveConfig, clearConfig, enabled, sendPayment } return ( {children} diff --git a/components/webln/nwc.js b/components/webln/nwc.js index edcd2862e..960c21286 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -12,6 +12,7 @@ export function NWCProvider ({ children }) { const [secret, setSecret] = useState() const [enabled, setEnabled] = useState() + const name = 'NWC' const storageKey = 'webln:provider:nwc' const loadConfig = useCallback(() => { @@ -149,7 +150,7 @@ export function NWCProvider ({ children }) { useEffect(loadConfig, []) - const value = { nwcUrl, relayUrl, walletPubkey, secret, saveConfig, clearConfig, enabled, sendPayment } + const value = { name, nwcUrl, relayUrl, walletPubkey, secret, saveConfig, clearConfig, enabled, sendPayment } return ( {children} From 7a063d77bcb21ad5cb1c0d343d25d68f9f752bbb Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 22 Jan 2024 08:24:37 +0100 Subject: [PATCH 31/75] Add code to mock NWC relay --- components/webln/nwc.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index 960c21286..72d0bd825 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -38,11 +38,24 @@ export function NWCProvider ({ children }) { return new Promise(function (resolve, reject) { (async function () { // need big timeout since NWC is async (user needs to confirm payment in wallet) - const timeout = 60000 + + // XXX set this to mock NWC relays + const MOCK_NWC_RELAY = 1 + + const timeout = MOCK_NWC_RELAY ? 3000 : 60000 let timer const resetTimer = () => { clearTimeout(timer) - timer = setTimeout(() => reject(new Error('timeout')), timeout) + timer = setTimeout(() => { + if (MOCK_NWC_RELAY) { + const heads = Math.random() < 0.5 + if (heads) { + return resolve() + } + return reject(new Error('mock error')) + } + return reject(new Error('timeout')) + }, timeout) } const relay = await Relay.connect(relayUrl) From 73072171e2b52cdba9da1ae6928520fb7570cf85 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 22 Jan 2024 08:37:48 +0100 Subject: [PATCH 32/75] Revert "Bold strike on WebLN zap" This reverts commit a9eb27daec0cd2ef30b56294b05e0056fb5b4184. --- components/invoice.js | 11 +++++------ components/item-act.js | 6 ++---- components/lightning.js | 10 +++++----- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index 658a66490..ec83dae66 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -218,7 +218,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { const inv = data.createInvoice // wait until invoice is paid or modal is closed - const payment = await waitForPayment({ + const modalClose = await waitForPayment({ invoice: inv, showModal, provider, @@ -226,12 +226,11 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { updateCache: () => update?.(client.cache, { data: optimisticResponse }), undoUpdate: () => update?.(client.cache, { data: { ...optimisticResponse, undo: true } }) }) - const { webLN, modalClose } = payment const retry = () => onSubmit( { hash: inv.hash, hmac: inv.hmac, ...formValues }, // unset update function since we already ran an cache update - { variables, update: null }, webLN) + { variables, update: null }) // first retry try { const ret = await retry() @@ -279,7 +278,7 @@ const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, updat provider.sendPayment(invoice.bolt11) // 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 })) + .then(resolve) .catch(err => { clearInterval(interval) reject(err) @@ -294,7 +293,7 @@ const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, updat const { invoice: inv } = data if (inv.isHeld && inv.satsReceived) { clearInterval(interval) - resolve({ webLN: true }) + resolve() } } catch (err) { clearInterval(interval) @@ -314,7 +313,7 @@ const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, updat return ( resolve({ modalClose: onClose })} + onPayment={() => resolve(onClose)} /> ) }, { keepOpen: true, onClose: reject }) diff --git a/components/item-act.js b/components/item-act.js index e86cc427e..9b8235015 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -235,11 +235,9 @@ export function useZap () { const [act] = useAct() const invoiceableAct = useInvoiceModal( - async ({ hash, hmac }, { variables, ...apolloArgs }, webLN) => { + async ({ hash, hmac }, { variables, ...apolloArgs }) => { await act({ variables: { ...variables, hash, hmac }, ...apolloArgs }) - if (!strike({ webLN })) { - toaster.success('WebLN zap successful') - } + strike() }, [act, strike]) return useCallback(async ({ item, me }) => { diff --git a/components/lightning.js b/components/lightning.js index f875ef14e..948abf56e 100644 --- a/components/lightning.js +++ b/components/lightning.js @@ -12,12 +12,12 @@ export class LightningProvider extends React.Component { * Strike lightning on the screen, if the user has the setting enabled * @returns boolean indicating whether the strike actually happened, based on user preferences */ - strike = ({ webLN } = {}) => { + strike = () => { const should = window.localStorage.getItem('lnAnimate') || 'yes' if (should === 'yes') { this.setState(state => { return { - bolts: [...state.bolts, this.unstrike(state.bolts.length)} />] + bolts: [...state.bolts, this.unstrike(state.bolts.length)} />] } }) return true @@ -49,7 +49,7 @@ export function useLightning () { return useContext(LightningContext) } -export function Lightning ({ onDone, webLN }) { +export function Lightning ({ onDone }) { const canvasRef = useRef(null) useEffect(() => { @@ -67,8 +67,7 @@ export function Lightning ({ onDone, webLN }) { speed: 100, spread: 30, branches: 20, - onDone, - lineWidth: webLN ? 7 : 3 + onDone }) canvas.bolt.draw() }, []) @@ -85,6 +84,7 @@ function Bolt (ctx, options) { spread: 50, branches: 10, maxBranches: 10, + lineWidth: 3, ...options } this.point = [this.options.startPoint[0], this.options.startPoint[1]] From 5ce79120997aed5db7e3e48e05ec9718f6171bc5 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 22 Jan 2024 09:43:29 +0100 Subject: [PATCH 33/75] Fix update undo --- components/invoice.js | 2 +- components/item-act.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index ec83dae66..bf37e16bb 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -224,7 +224,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { provider, pollInvoice, updateCache: () => update?.(client.cache, { data: optimisticResponse }), - undoUpdate: () => update?.(client.cache, { data: { ...optimisticResponse, undo: true } }) + undoUpdate: () => update?.(client.cache, { data: { ...optimisticResponse }, undo: true }) }) const retry = () => onSubmit( diff --git a/components/item-act.js b/components/item-act.js index 9b8235015..19fad7021 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -106,8 +106,8 @@ export default function ItemAct ({ onClose, itemId, down, children }) { export function useAct ({ onUpdate } = {}) { const me = useMe() - const update = useCallback((cache, args, undo) => { - const { data: { act: { id, sats, path, act, amount } } } = args + const update = useCallback((cache, args) => { + const { data: { act: { id, sats, path, act, amount } }, undo } = args cache.modify({ id: `Item:${id}`, @@ -173,7 +173,7 @@ export function useAct ({ onUpdate } = {}) { export function useZap () { const update = useCallback((cache, args) => { - const { data: { act: { id, sats, path, amount, undo } } } = args + const { data: { act: { id, sats, path, amount } }, undo } = args // determine how much we increased existing sats by by checking the // difference between result sats and meSats From 2eca9ebdff2a8a25c52f7f0b1b686094518fc5c8 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 22 Jan 2024 10:02:28 +0100 Subject: [PATCH 34/75] Fix lightning strike before payment --- components/item-act.js | 1 + components/upvote.js | 4 ---- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/components/item-act.js b/components/item-act.js index 19fad7021..355062a3f 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -257,6 +257,7 @@ export function useZap () { const insufficientFunds = me?.privates.sats < sats const optimisticResponse = { act: { path: item.path, ...variables } } try { + if (!insufficientFunds) strike() await zap({ variables, optimisticResponse: insufficientFunds ? null : optimisticResponse }) } catch (error) { if (payOrLoginError(error)) { 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 => ) From 7c7f652b8b0afd342ec423f28737572eb7086eea Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 22 Jan 2024 08:25:40 +0100 Subject: [PATCH 35/75] WIP: Wrap WebLN payments with toasts * add toasts for pending, error, success * while pending, invoice can be canceled * there are still some race conditions between payiny the invoice / error on payment and invoice cancellation --- components/invoice.js | 12 ++++++++++-- components/webln/index.js | 41 ++++++++++++++++++++++++++++++++++++++- pages/_app.js | 32 +++++++++++++++--------------- 3 files changed, 66 insertions(+), 19 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index bf37e16bb..382e7c16e 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -267,6 +267,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { } const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, updateCache, undoUpdate }) => { + const INVOICE_CANCELED_ERROR = 'invoice was canceled' try { // try WebLN provider first return await new Promise((resolve, reject) => { @@ -275,7 +276,7 @@ const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, updat // 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.bolt11) + 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) @@ -295,6 +296,10 @@ const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, updat clearInterval(interval) resolve() } + if (inv.cancelled) { + clearInterval(interval) + reject(new Error(INVOICE_CANCELED_ERROR)) + } } catch (err) { clearInterval(interval) reject(err) @@ -302,9 +307,12 @@ const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, updat }, 1000) }) } catch (err) { - console.error('WebLN payment failed:', err) // undo attempt to make zapping UX consistent undoUpdate?.() + console.error('WebLN payment failed:', err) + if (err.message === INVOICE_CANCELED_ERROR) { + throw err + } } // QR code as fallback diff --git a/components/webln/index.js b/components/webln/index.js index 2825ea53e..5d8387b4f 100644 --- a/components/webln/index.js +++ b/components/webln/index.js @@ -1,18 +1,57 @@ import { createContext, useContext } 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({}) function RawWebLNProvider ({ children }) { const lnbits = useLNbits() const nwc = useNWC() + const toaster = useToast() + const [cancelInvoice] = useMutation(gql` + mutation cancelInvoice($hash: String!, $hmac: String!) { + cancelInvoice(hash: $hash, hmac: $hmac) { + id + } + } + `) // TODO: switch between providers based on user preference const provider = nwc + const sendPaymentWithToast = function ({ bolt11, hash, hmac }) { + let canceled = false + let removeToast = toaster.warning('zap pending', { + autohide: false, + onCancel: async () => { + try { + await cancelInvoice({ variables: { hash, hmac } }) + canceled = true + toaster.warning('zap canceled') + removeToast = undefined + } catch (err) { + toaster.danger('failed to cancel zap') + } + } + }) + return provider.sendPayment(bolt11) + .then(() => { + if (canceled) return + removeToast?.() + if (!canceled) toaster.success('zap successful') + }).catch((err) => { + if (canceled) return + removeToast?.() + const reason = err?.message?.toString().toLowerCase() || 'unknown reason' + toaster.danger(`zap failed: ${reason}`) + throw err + }) + } + return ( - + {children} ) diff --git a/pages/_app.js b/pages/_app.js index 7847d1267..b537811dd 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -92,14 +92,14 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - - - - - - - - + + + + + + + + @@ -110,14 +110,14 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - - - - - - - - + + + + + + + + From 17c455a23682e053c0fca6af925f0707f994dad7 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 28 Jan 2024 00:34:58 +0100 Subject: [PATCH 36/75] Fix invoice poll using stale value from cache --- components/invoice.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/invoice.js b/components/invoice.js index 382e7c16e..1af8742aa 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -182,7 +182,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { const showModal = useShowModal() const provider = useWebLN() const client = useApolloClient() - const pollInvoice = (id) => client.query({ query: INVOICE, variables: { id } }) + const pollInvoice = (id) => client.query({ query: INVOICE, fetchPolicy: 'no-cache', variables: { id } }) const onSubmitWrapper = useCallback(async ( { cost, ...formValues }, From ecbdec0db8efaaa890b38bc8a7a67e00897bf7bc Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 29 Jan 2024 15:37:13 +0100 Subject: [PATCH 37/75] Remove unnecessary if --- components/webln/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/webln/index.js b/components/webln/index.js index 5d8387b4f..0975d092e 100644 --- a/components/webln/index.js +++ b/components/webln/index.js @@ -40,7 +40,7 @@ function RawWebLNProvider ({ children }) { .then(() => { if (canceled) return removeToast?.() - if (!canceled) toaster.success('zap successful') + toaster.success('zap successful') }).catch((err) => { if (canceled) return removeToast?.() From c479ca10b94668a880908468d8fa72542f79f681 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Tue, 30 Jan 2024 05:02:08 +0100 Subject: [PATCH 38/75] Make sure that pay_invoice is declared as supported --- components/webln/nwc.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index 72d0bd825..bdee095a4 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -117,12 +117,20 @@ export function NWCProvider ({ children }) { authors: [walletPubkey] } ], { + onevent (event) { + console.log(event) + const supported = event.content.split() + resolve(supported) + }, // some relays like nostr.mutinywallet.com don't support NIP-47 info events // so we simply check that we received EOSE oneose () { clearTimeout(timer) sub.close() - resolve() + // we assume that pay_invoice is supported + // (which should be mandatory to support since it's described in NIP-47) + const supported = ['pay_invoice'] + resolve(supported) } }) })().catch(reject) @@ -137,8 +145,8 @@ export function NWCProvider ({ children }) { return } try { - await getInfo() - setEnabled(true) + const supported = await getInfo() + setEnabled(supported.includes('pay_invoice')) } catch (err) { console.error(err) setEnabled(false) From 3fdd9789ab9cae8daa508af4a3527be7eaabb54e Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 31 Jan 2024 04:13:14 +0100 Subject: [PATCH 39/75] Check if WebLN provider is enabled before calling sendPayment --- components/invoice.js | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index 1af8742aa..4cc62334f 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -266,8 +266,26 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { return onSubmitWrapper } +const INVOICE_CANCELED_ERROR = 'invoice was canceled' const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, updateCache, undoUpdate }) => { - const INVOICE_CANCELED_ERROR = 'invoice was canceled' + if (provider.enabled) { + return await waitForWebLNPayment({ provider, invoice, pollInvoice, updateCache }) + } + + // QR code as fallback + return await new Promise((resolve, reject) => { + showModal(onClose => { + return ( + resolve(onClose)} + /> + ) + }, { keepOpen: true, onClose: reject }) + }) +} + +const waitForWebLNPayment = async ({ provider, invoice, pollInvoice, updateCache, undoUpdate }) => { try { // try WebLN provider first return await new Promise((resolve, reject) => { @@ -314,18 +332,6 @@ const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, updat throw err } } - - // QR code as fallback - return await new Promise((resolve, reject) => { - showModal(onClose => { - return ( - resolve(onClose)} - /> - ) - }, { keepOpen: true, onClose: reject }) - }) } export const useInvoiceModal = (onPayment, deps) => { From 4239568e96d7bb13459da3d36294db7636f02509 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 31 Jan 2024 04:27:13 +0100 Subject: [PATCH 40/75] Fix bad retry If WebLN payments failed due to insufficient balances, the promise resolved and thus the action was retried but failed immediately since the invoice (still) wasn't paid. --- components/invoice.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index 4cc62334f..66606892f 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -269,7 +269,15 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { const INVOICE_CANCELED_ERROR = 'invoice was canceled' const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, updateCache, undoUpdate }) => { if (provider.enabled) { - return await waitForWebLNPayment({ provider, invoice, pollInvoice, updateCache }) + try { + return await waitForWebLNPayment({ provider, invoice, pollInvoice, updateCache }) + } 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 @@ -328,9 +336,7 @@ const waitForWebLNPayment = async ({ provider, invoice, pollInvoice, updateCache // undo attempt to make zapping UX consistent undoUpdate?.() console.error('WebLN payment failed:', err) - if (err.message === INVOICE_CANCELED_ERROR) { - throw err - } + throw err } } From c23f7f193a891de57e83a85bd49ddf00f34e7fde Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 31 Jan 2024 04:55:28 +0100 Subject: [PATCH 41/75] Fix cache undo update --- components/invoice.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/invoice.js b/components/invoice.js index 66606892f..dab7eadc5 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -270,7 +270,7 @@ const INVOICE_CANCELED_ERROR = 'invoice was canceled' const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, updateCache, undoUpdate }) => { if (provider.enabled) { try { - return await waitForWebLNPayment({ provider, invoice, pollInvoice, updateCache }) + return await waitForWebLNPayment({ provider, invoice, pollInvoice, updateCache, undoUpdate }) } catch (err) { const INVOICE_CANCELED_ERROR = 'invoice was canceled' // check for errors which mean that QR code will also fail From 477eb5cc502b3633652f1a5e2d23fd1b137a06bb Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 31 Jan 2024 04:59:07 +0100 Subject: [PATCH 42/75] Fix no cache update after QR payment --- components/invoice.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/invoice.js b/components/invoice.js index dab7eadc5..a028d68c1 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -230,7 +230,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { const retry = () => onSubmit( { hash: inv.hash, hmac: inv.hmac, ...formValues }, // unset update function since we already ran an cache update - { variables, update: null }) + { variables }) // first retry try { const ret = await retry() From 9f5dcb7e6c52221bc49b5185d7346a2773a9d978 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 31 Jan 2024 05:52:50 +0100 Subject: [PATCH 43/75] refactor: Use fragments to undo cache updates --- components/invoice.js | 39 ++++++++++++++++++++++++++++++++------- components/item-act.js | 20 ++++++++++---------- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index a028d68c1..743fdcbec 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -217,14 +217,39 @@ 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 const modalClose = await waitForPayment({ invoice: inv, showModal, provider, pollInvoice, - updateCache: () => update?.(client.cache, { data: optimisticResponse }), - undoUpdate: () => update?.(client.cache, { data: { ...optimisticResponse }, undo: true }) + gqlCacheUpdate: _update }) const retry = () => onSubmit( @@ -267,10 +292,10 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { } const INVOICE_CANCELED_ERROR = 'invoice was canceled' -const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, updateCache, undoUpdate }) => { +const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, gqlCacheUpdate }) => { if (provider.enabled) { try { - return await waitForWebLNPayment({ provider, invoice, pollInvoice, updateCache, undoUpdate }) + 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 @@ -293,12 +318,13 @@ const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, updat }) } -const waitForWebLNPayment = async ({ provider, invoice, pollInvoice, updateCache, undoUpdate }) => { +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 - updateCache?.() + 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 @@ -333,7 +359,6 @@ const waitForWebLNPayment = async ({ provider, invoice, pollInvoice, updateCache }, 1000) }) } catch (err) { - // undo attempt to make zapping UX consistent undoUpdate?.() console.error('WebLN payment failed:', err) throw err diff --git a/components/item-act.js b/components/item-act.js index 355062a3f..574d18b17 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -107,14 +107,14 @@ export function useAct ({ onUpdate } = {}) { const me = useMe() const update = useCallback((cache, args) => { - const { data: { act: { id, sats, path, act, amount } }, undo } = args + const { data: { act: { id, sats, path, act } } } = args cache.modify({ id: `Item:${id}`, fields: { sats (existingSats = 0) { if (act === 'TIP') { - return existingSats + (undo ? -amount : sats) + return existingSats + sats } return existingSats @@ -122,7 +122,7 @@ export function useAct ({ onUpdate } = {}) { meSats: me ? (existingSats = 0) => { if (act === 'TIP') { - return existingSats + (undo ? -amount : sats) + return existingSats + sats } return existingSats @@ -131,7 +131,7 @@ export function useAct ({ onUpdate } = {}) { meDontLikeSats: me ? (existingSats = 0) => { if (act === 'DONT_LIKE_THIS') { - return existingSats + (undo ? -amount : sats) + return existingSats + sats } return existingSats @@ -148,7 +148,7 @@ export function useAct ({ onUpdate } = {}) { id: `Item:${aId}`, fields: { commentSats (existingCommentSats = 0) { - return existingCommentSats + (undo ? -amount : sats) + return existingCommentSats + sats } } }) @@ -173,7 +173,7 @@ export function useAct ({ onUpdate } = {}) { export function useZap () { const update = useCallback((cache, args) => { - const { data: { act: { id, sats, path, amount } }, undo } = args + const { data: { act: { id, sats, path } } } = args // determine how much we increased existing sats by by checking the // difference between result sats and meSats @@ -191,15 +191,15 @@ export function useZap () { const satsDelta = sats - item.meSats - if (satsDelta >= 0) { + if (satsDelta > 0) { cache.modify({ id: `Item:${id}`, fields: { sats (existingSats = 0) { - return existingSats + (undo ? -amount : satsDelta) + return existingSats + satsDelta }, meSats: () => { - return undo ? sats - amount : sats + return sats } } }) @@ -211,7 +211,7 @@ export function useZap () { id: `Item:${aId}`, fields: { commentSats (existingCommentSats = 0) { - return existingCommentSats + (undo ? -amount : satsDelta) + return existingCommentSats + satsDelta } } }) From 263a056151f4a8c0ee797e58bff306b6e216a3d9 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 31 Jan 2024 06:27:00 +0100 Subject: [PATCH 44/75] Remove console.log --- components/webln/nwc.js | 1 - 1 file changed, 1 deletion(-) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index bdee095a4..7ce2fa4b6 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -118,7 +118,6 @@ export function NWCProvider ({ children }) { } ], { onevent (event) { - console.log(event) const supported = event.content.split() resolve(supported) }, From 01de653c7ea63e107cc5b23db20894af02a05011 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 31 Jan 2024 07:00:50 +0100 Subject: [PATCH 45/75] Small changes to NWC relay mocking --- components/webln/nwc.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index 7ce2fa4b6..f1be0c534 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -40,7 +40,7 @@ export function NWCProvider ({ children }) { // need big timeout since NWC is async (user needs to confirm payment in wallet) // XXX set this to mock NWC relays - const MOCK_NWC_RELAY = 1 + const MOCK_NWC_RELAY = true const timeout = MOCK_NWC_RELAY ? 3000 : 60000 let timer @@ -57,6 +57,7 @@ export function NWCProvider ({ children }) { return reject(new Error('timeout')) }, timeout) } + if (MOCK_NWC_RELAY) return resetTimer() const relay = await Relay.connect(relayUrl) From af733aa8d83abc64ea173887e3b5626ea94ca366 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 31 Jan 2024 08:24:02 +0100 Subject: [PATCH 46/75] Return SendPaymentResponse See https://www.webln.guide/building-lightning-apps/webln-reference/webln.sendpayment --- components/webln/index.js | 4 ++-- components/webln/lnbits.js | 4 +--- components/webln/nwc.js | 4 ++-- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/components/webln/index.js b/components/webln/index.js index 0975d092e..b80f9d188 100644 --- a/components/webln/index.js +++ b/components/webln/index.js @@ -37,10 +37,10 @@ function RawWebLNProvider ({ children }) { } }) return provider.sendPayment(bolt11) - .then(() => { - if (canceled) return + .then(({ preimage }) => { removeToast?.() toaster.success('zap successful') + return { preimage } }).catch((err) => { if (canceled) return removeToast?.() diff --git a/components/webln/lnbits.js b/components/webln/lnbits.js index ac43d30df..7754314a8 100644 --- a/components/webln/lnbits.js +++ b/components/webln/lnbits.js @@ -76,9 +76,7 @@ export function LNbitsProvider ({ children }) { if (!checkResponse.preimage) { throw new Error('No preimage') } - return { - preimage: checkResponse.preimage - } + return { preimage: checkResponse.preimage } }, [request]) const loadConfig = useCallback(() => { diff --git a/components/webln/nwc.js b/components/webln/nwc.js index f1be0c534..7e27f3b62 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -50,7 +50,7 @@ export function NWCProvider ({ children }) { if (MOCK_NWC_RELAY) { const heads = Math.random() < 0.5 if (heads) { - return resolve() + return resolve({ preimage: null }) } return reject(new Error('mock error')) } @@ -90,7 +90,7 @@ export function NWCProvider ({ children }) { 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(content.result.preimage) + if (content.result) return resolve({ preimage: content.result.preimage }) } catch (err) { return reject(err) } finally { From 49abf9878c508b064b25eea1913073c82e9f150e Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 31 Jan 2024 08:29:34 +0100 Subject: [PATCH 47/75] Also undo cache update on retry failure --- components/invoice.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index 743fdcbec..7e36a070b 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -244,7 +244,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { } // wait until invoice is paid or modal is closed - const modalClose = await waitForPayment({ + const { modalClose, gqlCacheUpdateUndo } = await waitForPayment({ invoice: inv, showModal, provider, @@ -262,6 +262,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { modalClose?.() return ret } catch (error) { + gqlCacheUpdateUndo?.() console.error('retry error:', error) } @@ -311,7 +312,7 @@ const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, gqlCa return ( resolve(onClose)} + onPayment={() => resolve({ modalOnClose: onClose })} /> ) }, { keepOpen: true, onClose: reject }) @@ -331,7 +332,7 @@ const waitForWebLNPayment = async ({ provider, invoice, pollInvoice, gqlCacheUpd 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) + .then(() => resolve({ gqlCacheUpdateUndo: undoUpdate })) .catch(err => { clearInterval(interval) reject(err) @@ -346,7 +347,7 @@ const waitForWebLNPayment = async ({ provider, invoice, pollInvoice, gqlCacheUpd const { invoice: inv } = data if (inv.isHeld && inv.satsReceived) { clearInterval(interval) - resolve() + resolve({ gqlCacheUpdateUndo: undoUpdate }) } if (inv.cancelled) { clearInterval(interval) From acf61353670350685eab9da3d14d60feb24bd6ab Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 31 Jan 2024 08:51:05 +0100 Subject: [PATCH 48/75] Disable NWC mocking --- components/webln/nwc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index 7e27f3b62..80ab3772c 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -40,7 +40,7 @@ export function NWCProvider ({ children }) { // need big timeout since NWC is async (user needs to confirm payment in wallet) // XXX set this to mock NWC relays - const MOCK_NWC_RELAY = true + const MOCK_NWC_RELAY = false const timeout = MOCK_NWC_RELAY ? 3000 : 60000 let timer From 7aef8c74b8e4453ec8a3e9492c43aa46050d9410 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 31 Jan 2024 09:13:42 +0100 Subject: [PATCH 49/75] Fix initialValue not set But following warning is now shown in console: """ Warning: A component is changing a controlled input to be uncontrolled. This is likely caused by the value changing from a defined to undefined, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components """ --- components/form.js | 14 ++++++++++++++ pages/settings/wallets/lnbits.js | 8 +++++--- pages/settings/wallets/nwc.js | 5 +++-- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/components/form.js b/components/form.js index 798dba216..1bbe3559a 100644 --- a/components/form.js +++ b/components/form.js @@ -961,3 +961,17 @@ export function DatePicker ({ fromName, toName, noForm, onChange, when, from, to /> ) } + +export function ClientInput ({ 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 +} diff --git a/pages/settings/wallets/lnbits.js b/pages/settings/wallets/lnbits.js index ccfdbe08b..231c53f4d 100644 --- a/pages/settings/wallets/lnbits.js +++ b/pages/settings/wallets/lnbits.js @@ -1,5 +1,5 @@ import { getGetServerSideProps } from '../../../api/ssrApollo' -import { Form, Input } from '../../../components/form' +import { Form, ClientInput } from '../../../components/form' import { CenterLayout } from '../../../components/layout' import { WalletButtonBar, WalletCard } from '../../../components/wallet-card' import { lnbitsSchema } from '../../../lib/validate' @@ -35,13 +35,15 @@ export default function LNbits () { } }} > - - - Date: Thu, 1 Feb 2024 16:21:41 +0100 Subject: [PATCH 50/75] Remove comment since only relevant for blastr (mutiny relay) --- components/webln/nwc.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index 80ab3772c..5a91e5939 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -77,8 +77,6 @@ export function NWCProvider ({ children }) { const filter = { kinds: [23195], - // for some reason, 'authors' must be set in the filter else you will debug your code for hours. - // this doesn't seem to be documented in NIP-01 or NIP-47. authors: [walletPubkey], '#e': [request.id] } From dcf4856b7b6d0bc1e59f7468dd83ffcc24d535a6 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 1 Feb 2024 16:34:25 +0100 Subject: [PATCH 51/75] Remove TODO --- components/webln/lnbits.js | 1 - 1 file changed, 1 deletion(-) diff --git a/components/webln/lnbits.js b/components/webln/lnbits.js index 7754314a8..9aefaf971 100644 --- a/components/webln/lnbits.js +++ b/components/webln/lnbits.js @@ -53,7 +53,6 @@ export function LNbitsProvider ({ children }) { 'getInfo', 'getBalance', 'sendPayment' - // TODO: support makeInvoice and sendPaymentAsync ], version: '1.0', supports: ['lightning'] From 7d454ef61e9b0ee510fb0e8a2b56a498c438cfac Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 1 Feb 2024 18:38:16 +0100 Subject: [PATCH 52/75] Fix duplicate cache update --- components/invoice.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index 7e36a070b..087ef6c48 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -252,10 +252,12 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { gqlCacheUpdate: _update }) + const webLnPayment = !!gqlCacheUpdateUndo + const retry = () => onSubmit( { hash: inv.hash, hmac: inv.hmac, ...formValues }, - // unset update function since we already ran an cache update - { variables }) + // unset update function since we already ran an cache update if we paid using WebLN + { variables, update: webLnPayment ? null : undefined }) // first retry try { const ret = await retry() From 036787be38ee2a58a42b02c873157a8afb74c3aa Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 1 Feb 2024 18:43:11 +0100 Subject: [PATCH 53/75] Fix QR modal not closed after payment --- components/invoice.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index 087ef6c48..09e8e95ea 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -244,7 +244,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { } // wait until invoice is paid or modal is closed - const { modalClose, gqlCacheUpdateUndo } = await waitForPayment({ + const { modalOnClose, gqlCacheUpdateUndo } = await waitForPayment({ invoice: inv, showModal, provider, @@ -261,7 +261,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { // first retry try { const ret = await retry() - modalClose?.() + modalOnClose?.() return ret } catch (error) { gqlCacheUpdateUndo?.() @@ -284,6 +284,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { }} onRetry={async () => { resolve(await retry()) + onClose() }} /> ) From aac5d2eee781d1bbd6d0cafeef9bd20bb9225163 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 1 Feb 2024 19:22:57 +0100 Subject: [PATCH 54/75] Ignore lnbits variable unused --- components/webln/index.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/components/webln/index.js b/components/webln/index.js index b80f9d188..4d2036a83 100644 --- a/components/webln/index.js +++ b/components/webln/index.js @@ -7,6 +7,9 @@ import { gql, useMutation } from '@apollo/client' const WebLNContext = createContext({}) function RawWebLNProvider ({ children }) { + // LNbits should only be used during development + // since it gives full wallet access on XSS + // eslint-disable-next-line no-unused-vars const lnbits = useLNbits() const nwc = useNWC() const toaster = useToast() From c91f9f22160b640a2d6cedfd37229c460060f0b4 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Fri, 2 Feb 2024 09:36:52 +0100 Subject: [PATCH 55/75] Use single relay connection for all NWC events --- components/webln/nwc.js | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index 5a91e5939..938140523 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -11,6 +11,7 @@ export function NWCProvider ({ children }) { const [relayUrl, setRelayUrl] = useState() const [secret, setSecret] = useState() const [enabled, setEnabled] = useState() + const [relay, setRelay] = useState() const name = 'NWC' const storageKey = 'webln:provider:nwc' @@ -34,6 +35,23 @@ export function NWCProvider ({ children }) { setNwcUrl(null) }, []) + 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 () { @@ -59,8 +77,6 @@ export function NWCProvider ({ children }) { } if (MOCK_NWC_RELAY) return resetTimer() - const relay = await Relay.connect(relayUrl) - const payload = { method: 'pay_invoice', params: { invoice: bolt11 } @@ -102,7 +118,7 @@ export function NWCProvider ({ children }) { }) })().catch(reject) }) - }, [relayUrl, walletPubkey, secret]) + }, [relay, walletPubkey, secret]) const getInfo = useCallback(() => { return new Promise(function (resolve, reject) { @@ -133,12 +149,12 @@ export function NWCProvider ({ children }) { }) })().catch(reject) }) - }, [relayUrl, walletPubkey]) + }, [relay, walletPubkey]) useEffect(() => { // update enabled (async function () { - if (!(relayUrl && walletPubkey && secret)) { + if (!(relay && walletPubkey && secret)) { setEnabled(undefined) return } From 2865ca4dd7c0ec68ad38d091f504fca021f41bea Mon Sep 17 00:00:00 2001 From: ekzyis Date: Fri, 2 Feb 2024 09:37:43 +0100 Subject: [PATCH 56/75] Fix missing timer and subscription cleanup --- components/webln/nwc.js | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index 938140523..5f7ce2c3a 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -55,16 +55,16 @@ export function NWCProvider ({ children }) { const sendPayment = useCallback((bolt11) => { return new Promise(function (resolve, reject) { (async function () { - // need big timeout since NWC is async (user needs to confirm payment in wallet) - // XXX set this to mock NWC relays const MOCK_NWC_RELAY = false + // need big timeout since NWC is async (user needs to confirm payment in wallet) const timeout = MOCK_NWC_RELAY ? 3000 : 60000 let timer const resetTimer = () => { clearTimeout(timer) timer = setTimeout(() => { + sub?.close() if (MOCK_NWC_RELAY) { const heads = Math.random() < 0.5 if (heads) { @@ -113,6 +113,7 @@ export function NWCProvider ({ children }) { } }, onclose (reason) { + clearTimeout(timer) reject(new Error(reason)) } }) @@ -124,8 +125,11 @@ export function NWCProvider ({ children }) { return new Promise(function (resolve, reject) { (async function () { const timeout = 5000 - const timer = setTimeout(() => reject(new Error('timeout')), timeout) - const relay = await Relay.connect(relayUrl) + const timer = setTimeout(() => { + sub?.close() + reject(new Error('timeout')) + }, timeout) + const sub = relay.subscribe([ { kinds: [13194], @@ -133,18 +137,24 @@ export function NWCProvider ({ children }) { } ], { onevent (event) { + clearTimeout(timer) const supported = event.content.split() resolve(supported) + sub.close() }, // some relays like nostr.mutinywallet.com don't support NIP-47 info events // so we simply check that we received EOSE oneose () { clearTimeout(timer) - sub.close() // we assume that pay_invoice is supported // (which should be mandatory to support since it's described in NIP-47) const supported = ['pay_invoice'] resolve(supported) + sub.close() + }, + onclose (reason) { + clearTimeout(timer) + reject(new Error(reason)) } }) })().catch(reject) From c106eaaec66babf996fe9e0e8d166dd22f7482cd Mon Sep 17 00:00:00 2001 From: ekzyis Date: Fri, 2 Feb 2024 09:39:35 +0100 Subject: [PATCH 57/75] Remove TODO Confirmed that nostr-tools verifies events and filters for us. See https://github.com/nbd-wtf/nostr-tools/blob/master/abstract-relay.ts#L161 --- components/webln/nwc.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index 5f7ce2c3a..8700ae532 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -99,8 +99,6 @@ export function NWCProvider ({ children }) { const sub = relay.subscribe([filter], { async onevent (response) { resetTimer() - // TODO: check if we need verification here. does nostr-tools verify events? - // can we trust the NWC relay to respect our filters? try { const content = JSON.parse(await nip04.decrypt(secret, walletPubkey, response.content)) if (content.error) return reject(new Error(content.error.message)) From a2f6181a4572f9b2526da485d2a8101fc0965c60 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Fri, 2 Feb 2024 11:09:44 +0100 Subject: [PATCH 58/75] Fix switch from controlled to uncontrolled input --- components/webln/lnbits.js | 8 ++++---- components/webln/nwc.js | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/components/webln/lnbits.js b/components/webln/lnbits.js index 9aefaf971..25c5d2312 100644 --- a/components/webln/lnbits.js +++ b/components/webln/lnbits.js @@ -5,8 +5,8 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea const LNbitsContext = createContext() export function LNbitsProvider ({ children }) { - const [url, setUrl] = useState() - const [adminKey, setAdminKey] = useState() + const [url, setUrl] = useState('') + const [adminKey, setAdminKey] = useState('') const [enabled, setEnabled] = useState() const name = 'LNbits' @@ -97,8 +97,8 @@ export function LNbitsProvider ({ children }) { const clearConfig = useCallback(() => { window.localStorage.removeItem(storageKey) - setUrl(null) - setAdminKey(null) + setUrl('') + setAdminKey('') }, []) useEffect(() => { diff --git a/components/webln/nwc.js b/components/webln/nwc.js index 8700ae532..23e1b3660 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -6,7 +6,7 @@ import { Relay, finalizeEvent, nip04 } from 'nostr-tools' const NWCContext = createContext() export function NWCProvider ({ children }) { - const [nwcUrl, setNwcUrl] = useState() + const [nwcUrl, setNwcUrl] = useState('') const [walletPubkey, setWalletPubkey] = useState() const [relayUrl, setRelayUrl] = useState() const [secret, setSecret] = useState() @@ -32,7 +32,7 @@ export function NWCProvider ({ children }) { const clearConfig = useCallback(() => { window.localStorage.removeItem(storageKey) - setNwcUrl(null) + setNwcUrl('') }, []) useEffect(() => { From c1f41c5c993249cd48f2956864e1c74109629130 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Fri, 2 Feb 2024 11:13:39 +0100 Subject: [PATCH 59/75] Show 'configure' on error --- components/wallet-card.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/wallet-card.js b/components/wallet-card.js index d5923c6b7..77579604c 100644 --- a/components/wallet-card.js +++ b/components/wallet-card.js @@ -7,6 +7,7 @@ import CancelButton from './cancel-button' import { SubmitButton } from './form' export function WalletCard ({ title, badges, provider, enabled }) { + const isConfigured = enabled === true || enabled === false return (
@@ -23,7 +24,7 @@ export function WalletCard ({ title, badges, provider, enabled }) { {provider && - {enabled + {isConfigured ? <>configure : <>attach} From 4a084c1c63afa3e55564611131be24ad68f2f3af Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 3 Feb 2024 23:51:17 +0100 Subject: [PATCH 60/75] Use budgetable instead of async --- pages/settings/wallets/nwc.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pages/settings/wallets/nwc.js b/pages/settings/wallets/nwc.js index 7ee3c145b..5af5162f7 100644 --- a/pages/settings/wallets/nwc.js +++ b/pages/settings/wallets/nwc.js @@ -63,7 +63,7 @@ export function NWCCard () { return ( From 8db4cd155701c5d9129712c0478fb8c499a8f88b Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 4 Feb 2024 18:17:30 +0100 Subject: [PATCH 61/75] Remove EOSE listener Only nostr.mutinywallet.com didn't respond with info events due to implementation-specific reasons. This is no longer the case. --- components/webln/nwc.js | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index 23e1b3660..0f3832b74 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -140,16 +140,6 @@ export function NWCProvider ({ children }) { resolve(supported) sub.close() }, - // some relays like nostr.mutinywallet.com don't support NIP-47 info events - // so we simply check that we received EOSE - oneose () { - clearTimeout(timer) - // we assume that pay_invoice is supported - // (which should be mandatory to support since it's described in NIP-47) - const supported = ['pay_invoice'] - resolve(supported) - sub.close() - }, onclose (reason) { clearTimeout(timer) reject(new Error(reason)) From 166445010c67412925f55133a71bb1cfa63cd2a5 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 4 Feb 2024 22:48:13 +0100 Subject: [PATCH 62/75] Use invoice expiry for NWC timeout I don't think there was a specific reason why I used 60 seconds initially. --- components/webln/nwc.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index 0f3832b74..c4b78f603 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -58,8 +58,9 @@ export function NWCProvider ({ children }) { // XXX set this to mock NWC relays const MOCK_NWC_RELAY = false - // need big timeout since NWC is async (user needs to confirm payment in wallet) - const timeout = MOCK_NWC_RELAY ? 3000 : 60000 + // 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) From 6252b25502b2d7ce3c050876a341fed755cb283a Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 7 Feb 2024 19:42:21 +0100 Subject: [PATCH 63/75] Validate LNbits config on save --- components/webln/lnbits.js | 154 +++++++++++++++++++++---------------- 1 file changed, 89 insertions(+), 65 deletions(-) diff --git a/components/webln/lnbits.js b/components/webln/lnbits.js index 25c5d2312..3d1977eb9 100644 --- a/components/webln/lnbits.js +++ b/components/webln/lnbits.js @@ -4,6 +4,62 @@ import { createContext, useCallback, useContext, useEffect, useState } from 'rea 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('') @@ -12,38 +68,8 @@ export function LNbitsProvider ({ children }) { const name = 'LNbits' const storageKey = 'webln:provider:lnbits' - const request = useCallback(async (method, path, args) => { - let body = null - const query = '' - const headers = new Headers() - headers.append('Accept', 'application/json') - headers.append('Content-Type', 'application/json') - headers.append('X-Api-Key', adminKey) - - if (method === 'POST') { - body = JSON.stringify(args) - } else if (args !== undefined) { - throw new Error('TODO: support args in GET') - // query = ... - } - const url_ = url.replace(/\/+$/, '') - const res = await fetch(url_ + path + query, { - method, - headers, - body - }) - if (!res.ok) { - const errBody = await res.json() - throw new Error(errBody.detail) - } - return (await res.json()) - }, [url, adminKey]) - const getInfo = useCallback(async () => { - const response = await request( - 'GET', - '/api/v1/wallet' - ) + const response = await getWallet(url, adminKey) return { node: { alias: response.name, @@ -57,70 +83,68 @@ export function LNbitsProvider ({ children }) { version: '1.0', supports: ['lightning'] } - }, [request]) + }, [url, adminKey]) const sendPayment = useCallback(async (bolt11) => { - const response = await request( - 'POST', - '/api/v1/payments', - { - bolt11, - out: true - } - ) - const checkResponse = await request( - 'GET', - `/api/v1/payments/${response.payment_hash}` - ) + 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 } - }, [request]) + }, [url, adminKey]) - const loadConfig = useCallback(() => { + const loadConfig = useCallback(async () => { const config = window.localStorage.getItem(storageKey) if (!config) return const configJSON = JSON.parse(config) setUrl(configJSON.url) setAdminKey(configJSON.adminKey) + + try { + // validate config by trying to fetch wallet + await getWallet(configJSON.url, configJSON.adminKey) + } catch (err) { + console.error('invalid LNbits config:', err) + setEnabled(false) + throw err + } + setEnabled(true) }, []) const saveConfig = useCallback(async (config) => { + // immediately store config so it's not lost even if config is invalid setUrl(config.url) setAdminKey(config.adminKey) + // 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(() => { - // update enabled - (async function () { - if (!(url && adminKey)) { - setEnabled(undefined) - return - } - try { - await getInfo() - setEnabled(true) - } catch (err) { - console.error(err) - setEnabled(false) - } - })() - }, [url, adminKey, getInfo]) - - useEffect(loadConfig, []) - - const value = { name, url, adminKey, saveConfig, clearConfig, enabled, sendPayment } + loadConfig().catch(console.error) + }, []) + + const value = { name, url, adminKey, saveConfig, clearConfig, enabled, getInfo, sendPayment } return ( {children} From 104611622e23ee283795c350e517dd91295ccba0 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 7 Feb 2024 21:34:54 +0100 Subject: [PATCH 64/75] Validate NWC config on save --- components/webln/nwc.js | 172 ++++++++++++++++++++++++---------------- 1 file changed, 102 insertions(+), 70 deletions(-) diff --git a/components/webln/nwc.js b/components/webln/nwc.js index c4b78f603..2d6c0562a 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -16,23 +16,69 @@ export function NWCProvider ({ children }) { const name = 'NWC' const storageKey = 'webln:provider:nwc' - const loadConfig = useCallback(() => { + const loadConfig = useCallback(async () => { const config = window.localStorage.getItem(storageKey) if (!config) return + const configJSON = JSON.parse(config) - setNwcUrl(configJSON.nwcUrl) + + const { nwcUrl } = configJSON + setNwcUrl(nwcUrl) + if (!nwcUrl) { + setEnabled(undefined) + return + } + + 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) => { - setNwcUrl(config.nwcUrl) + // immediately store config so it's not lost even if config is invalid + const { nwcUrl } = config + setNwcUrl(nwcUrl) + 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(() => { @@ -120,71 +166,13 @@ export function NWCProvider ({ children }) { }) }, [relay, walletPubkey, secret]) - const getInfo = useCallback(() => { - return new Promise(function (resolve, reject) { - (async function () { - const timeout = 5000 - const timer = setTimeout(() => { - sub?.close() - reject(new Error('timeout')) - }, 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)) - } - }) - })().catch(reject) - }) - }, [relay, walletPubkey]) - - useEffect(() => { - // update enabled - (async function () { - if (!(relay && walletPubkey && secret)) { - setEnabled(undefined) - return - } - try { - const supported = await getInfo() - setEnabled(supported.includes('pay_invoice')) - } catch (err) { - console.error(err) - setEnabled(false) - } - })() - }, [relayUrl, walletPubkey, secret, getInfo]) + const getInfo = useCallback(() => getInfoWithRelay(relay, walletPubkey), [relay, walletPubkey]) useEffect(() => { - // parse nwc URL on updates - // and sync with other state variables - if (!nwcUrl) { - setRelayUrl(null) - setWalletPubkey(null) - setSecret(null) - return - } - const params = parseWalletConnectUrl(nwcUrl) - setRelayUrl(params.relayUrl) - setWalletPubkey(params.walletPubkey) - setSecret(params.secret) - }, [nwcUrl]) - - useEffect(loadConfig, []) + loadConfig().catch(console.error) + }, []) - const value = { name, nwcUrl, relayUrl, walletPubkey, secret, saveConfig, clearConfig, enabled, sendPayment } + const value = { name, nwcUrl, relayUrl, walletPubkey, secret, saveConfig, clearConfig, enabled, getInfo, sendPayment } return ( {children} @@ -196,20 +184,64 @@ 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 options = {} - options.walletPubkey = url.host + const params = {} + params.walletPubkey = url.host const secret = url.searchParams.get('secret') const relayUrl = url.searchParams.get('relay') if (secret) { - options.secret = secret + params.secret = secret } if (relayUrl) { - options.relayUrl = relayUrl + params.relayUrl = relayUrl } - return options + return params } From b38377ba16bda525c6ff9be1e7ff916ade650900 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 7 Feb 2024 21:36:17 +0100 Subject: [PATCH 65/75] Also show unattach if configuration is invalid If unattach is only shown if configuration is valid, resetting the configuration is not possible while it's invalid. So we're stuck with a red wallet indicator. --- components/wallet-card.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/wallet-card.js b/components/wallet-card.js index 77579604c..dcfa0385e 100644 --- a/components/wallet-card.js +++ b/components/wallet-card.js @@ -41,7 +41,7 @@ export function WalletButtonBar ({ return (
- {enabled && + {enabled !== undefined && } {children}
From 19c725ee38f7d1f4eb179e9908f6946a4c1e4e8c Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 7 Feb 2024 22:30:17 +0100 Subject: [PATCH 66/75] Fix detection of WebLN payment It depended on a Apollo cache update function being available. But that is not the case for every WebLN payment. --- components/invoice.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index 09e8e95ea..960a01a8b 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -244,7 +244,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { } // wait until invoice is paid or modal is closed - const { modalOnClose, gqlCacheUpdateUndo } = await waitForPayment({ + const { modalOnClose, webLn, gqlCacheUpdateUndo } = await waitForPayment({ invoice: inv, showModal, provider, @@ -252,12 +252,10 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { gqlCacheUpdate: _update }) - const webLnPayment = !!gqlCacheUpdateUndo - 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 - { variables, update: webLnPayment ? null : undefined }) + { variables, update: webLn ? null : undefined }) // first retry try { const ret = await retry() @@ -335,7 +333,7 @@ const waitForWebLNPayment = async ({ provider, invoice, pollInvoice, gqlCacheUpd 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({ gqlCacheUpdateUndo: undoUpdate })) + .then(() => resolve({ webLn: true, gqlCacheUpdateUndo: undoUpdate })) .catch(err => { clearInterval(interval) reject(err) @@ -350,7 +348,7 @@ const waitForWebLNPayment = async ({ provider, invoice, pollInvoice, gqlCacheUpd const { invoice: inv } = data if (inv.isHeld && inv.satsReceived) { clearInterval(interval) - resolve({ gqlCacheUpdateUndo: undoUpdate }) + resolve({ webLn: true, gqlCacheUpdateUndo: undoUpdate }) } if (inv.cancelled) { clearInterval(interval) From cc6c4609e0e74f4112d291bec91b7b91a626002b Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 7 Feb 2024 22:31:41 +0100 Subject: [PATCH 67/75] Fix formik bag lost --- components/invoice.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index 960a01a8b..a356e4c3b 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -186,7 +186,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { const onSubmitWrapper = useCallback(async ( { cost, ...formValues }, - { variables, optimisticResponse, update, ...apolloArgs }) => { + { variables, optimisticResponse, update, ...submitArgs }) => { // some actions require a session if (!me && options.requireSession) { throw new Error('you must be logged in') @@ -201,7 +201,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { try { const insufficientFunds = me?.privates.sats < cost return await onSubmit(formValues, - { variables, optimisticsResponse: insufficientFunds ? null : optimisticResponse, ...apolloArgs }) + { ...submitArgs, variables, optimisticsResponse: insufficientFunds ? null : optimisticResponse }) } catch (error) { if (!payOrLoginError(error) || !cost) { // can't handle error here - bail @@ -255,7 +255,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { 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 - { variables, update: webLn ? null : undefined }) + { ...submitArgs, variables, update: webLn ? null : undefined }) // first retry try { const ret = await retry() From ac347c73a9744acdaf509cbcc8ad634a713d1363 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 7 Feb 2024 22:39:00 +0100 Subject: [PATCH 68/75] Use payment instead of zap in toast --- components/webln/index.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/components/webln/index.js b/components/webln/index.js index 4d2036a83..a610d0e9f 100644 --- a/components/webln/index.js +++ b/components/webln/index.js @@ -26,29 +26,29 @@ function RawWebLNProvider ({ children }) { const sendPaymentWithToast = function ({ bolt11, hash, hmac }) { let canceled = false - let removeToast = toaster.warning('zap pending', { + let removeToast = toaster.warning('payment pending', { autohide: false, onCancel: async () => { try { await cancelInvoice({ variables: { hash, hmac } }) canceled = true - toaster.warning('zap canceled') + toaster.warning('payment canceled') removeToast = undefined } catch (err) { - toaster.danger('failed to cancel zap') + toaster.danger('failed to cancel payment') } } }) return provider.sendPayment(bolt11) .then(({ preimage }) => { removeToast?.() - toaster.success('zap successful') + toaster.success('payment successful') return { preimage } }).catch((err) => { if (canceled) return removeToast?.() const reason = err?.message?.toString().toLowerCase() || 'unknown reason' - toaster.danger(`zap failed: ${reason}`) + toaster.danger(`payment failed: ${reason}`) throw err }) } From 08401e91ff7c3b956249d2359759aabab323e71c Mon Sep 17 00:00:00 2001 From: keyan Date: Tue, 6 Feb 2024 13:53:43 -0600 Subject: [PATCH 69/75] autoscale capture svc by response time --- copilot/capture/manifest.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/copilot/capture/manifest.yml b/copilot/capture/manifest.yml index d636e6498..2c7dc6462 100644 --- a/copilot/capture/manifest.yml +++ b/copilot/capture/manifest.yml @@ -31,8 +31,6 @@ count: cooldown: in: 60s # Number of seconds to wait before scaling up. out: 60s # Number of seconds to wait before scaling down. - cpu_percentage: 50 # Percentage of CPU to target for autoscaling. - memory_percentage: 60 # Percentage of memory to target for autoscaling. response_time: 3s exec: true # Enable running commands in your container. network: From 838fab28f53409181d92610028f4a1030a6c9eb9 Mon Sep 17 00:00:00 2001 From: keyan Date: Wed, 7 Feb 2024 15:56:17 -0600 Subject: [PATCH 70/75] docs and changes for testing lnbits locally --- .gitignore | 1 + components/webln/index.js | 2 +- docs/attach-lnbits.md | 81 +++++++++++++++++++++++++++++++++++++++ lib/validate.js | 6 ++- 4 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 docs/attach-lnbits.md 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/webln/index.js b/components/webln/index.js index a610d0e9f..77393c12c 100644 --- a/components/webln/index.js +++ b/components/webln/index.js @@ -22,7 +22,7 @@ function RawWebLNProvider ({ children }) { `) // TODO: switch between providers based on user preference - const provider = nwc + const provider = nwc.enabled ? nwc : lnbits const sendPaymentWithToast = function ({ bolt11, hash, hmac }) { let canceled = false 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 69f8f8830..2526b4d5f 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -422,7 +422,11 @@ export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) => }, {}))) export const lnbitsSchema = object({ - url: string().url().required('required').trim(), + 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) }) From 4873c2f8c14dc7a4031624459ac326c52a37b833 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 8 Feb 2024 14:33:13 +0100 Subject: [PATCH 71/75] Rename configJSON to config Naming of config object was inconsistent with saveConfig function which was annoying. Also fixed other inconsistencies between LNbits and NWC provider. --- components/webln/lnbits.js | 20 +++++++++++++------- components/webln/nwc.js | 15 +++++++-------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/components/webln/lnbits.js b/components/webln/lnbits.js index 3d1977eb9..578d5c210 100644 --- a/components/webln/lnbits.js +++ b/components/webln/lnbits.js @@ -95,21 +95,27 @@ export function LNbitsProvider ({ children }) { }, [url, adminKey]) const loadConfig = useCallback(async () => { - const config = window.localStorage.getItem(storageKey) - if (!config) return - const configJSON = JSON.parse(config) - setUrl(configJSON.url) - setAdminKey(configJSON.adminKey) + const configStr = window.localStorage.getItem(storageKey) + if (!configStr) { + setEnabled(undefined) + return + } + + const config = JSON.parse(configStr) + + const { url, adminKey } = config + setUrl(url) + setAdminKey(adminKey) try { // validate config by trying to fetch wallet - await getWallet(configJSON.url, configJSON.adminKey) + await getWallet(url, adminKey) + setEnabled(true) } catch (err) { console.error('invalid LNbits config:', err) setEnabled(false) throw err } - setEnabled(true) }, []) const saveConfig = useCallback(async (config) => { diff --git a/components/webln/nwc.js b/components/webln/nwc.js index 2d6c0562a..c3e887876 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -17,18 +17,17 @@ export function NWCProvider ({ children }) { const storageKey = 'webln:provider:nwc' const loadConfig = useCallback(async () => { - const config = window.localStorage.getItem(storageKey) - if (!config) return - - const configJSON = JSON.parse(config) - - const { nwcUrl } = configJSON - setNwcUrl(nwcUrl) - if (!nwcUrl) { + const configStr = window.localStorage.getItem(storageKey) + if (!configStr) { setEnabled(undefined) return } + const config = JSON.parse(configStr) + + const { nwcUrl } = config + setNwcUrl(nwcUrl) + const params = parseWalletConnectUrl(nwcUrl) setRelayUrl(params.relayUrl) setWalletPubkey(params.walletPubkey) From 4b95addfcdd0e5488cc1f66998136a5a680db3d0 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 8 Feb 2024 17:17:47 +0100 Subject: [PATCH 72/75] Allow setting of default payment provider --- components/form.js | 29 +++++++------ components/webln/index.js | 72 ++++++++++++++++++++++++++++++-- components/webln/lnbits.js | 7 +++- components/webln/nwc.js | 9 ++-- pages/settings/wallets/lnbits.js | 13 ++++-- pages/settings/wallets/nwc.js | 13 ++++-- 6 files changed, 116 insertions(+), 27 deletions(-) diff --git a/components/form.js b/components/form.js index 1bbe3559a..9a1d1f0b1 100644 --- a/components/form.js +++ b/components/form.js @@ -962,16 +962,21 @@ export function DatePicker ({ fromName, toName, noForm, onChange, when, from, to ) } -export function ClientInput ({ 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 +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/webln/index.js b/components/webln/index.js index 77393c12c..c18a43d6c 100644 --- a/components/webln/index.js +++ b/components/webln/index.js @@ -1,17 +1,47 @@ -import { createContext, useContext } from 'react' +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 - // eslint-disable-next-line no-unused-vars const lnbits = useLNbits() const nwc = useNWC() + const providers = [lnbits, nwc] + + // order of payment methods depends on user preference: + // payment method at index 0 is default, if that one fails + // we try the remaining ones in order as fallbacks. + // -- TODO: implement fallback logic + // 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!) { @@ -21,8 +51,42 @@ function RawWebLNProvider ({ children }) { } `) - // TODO: switch between providers based on user preference - const provider = nwc.enabled ? nwc : lnbits + 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 + // 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 diff --git a/components/webln/lnbits.js b/components/webln/lnbits.js index 578d5c210..5bf5cf207 100644 --- a/components/webln/lnbits.js +++ b/components/webln/lnbits.js @@ -64,6 +64,7 @@ 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' @@ -103,9 +104,10 @@ export function LNbitsProvider ({ children }) { const config = JSON.parse(configStr) - const { url, adminKey } = config + const { url, adminKey, isDefault } = config setUrl(url) setAdminKey(adminKey) + setIsDefault(isDefault) try { // validate config by trying to fetch wallet @@ -122,6 +124,7 @@ export function LNbitsProvider ({ children }) { // 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 @@ -150,7 +153,7 @@ export function LNbitsProvider ({ children }) { loadConfig().catch(console.error) }, []) - const value = { name, url, adminKey, saveConfig, clearConfig, enabled, getInfo, sendPayment } + const value = { name, url, adminKey, saveConfig, clearConfig, enabled, isDefault, setIsDefault, getInfo, sendPayment } return ( {children} diff --git a/components/webln/nwc.js b/components/webln/nwc.js index c3e887876..ac72209a1 100644 --- a/components/webln/nwc.js +++ b/components/webln/nwc.js @@ -11,6 +11,7 @@ export function NWCProvider ({ children }) { const [relayUrl, setRelayUrl] = useState() const [secret, setSecret] = useState() const [enabled, setEnabled] = useState() + const [isDefault, setIsDefault] = useState() const [relay, setRelay] = useState() const name = 'NWC' @@ -25,8 +26,9 @@ export function NWCProvider ({ children }) { const config = JSON.parse(configStr) - const { nwcUrl } = config + const { nwcUrl, isDefault } = config setNwcUrl(nwcUrl) + setIsDefault(isDefault) const params = parseWalletConnectUrl(nwcUrl) setRelayUrl(params.relayUrl) @@ -45,8 +47,9 @@ export function NWCProvider ({ children }) { const saveConfig = useCallback(async (config) => { // immediately store config so it's not lost even if config is invalid - const { nwcUrl } = config + const { nwcUrl, isDefault } = config setNwcUrl(nwcUrl) + setIsDefault(isDefault) if (!nwcUrl) { setEnabled(undefined) return @@ -171,7 +174,7 @@ export function NWCProvider ({ children }) { loadConfig().catch(console.error) }, []) - const value = { name, nwcUrl, relayUrl, walletPubkey, secret, saveConfig, clearConfig, enabled, getInfo, sendPayment } + const value = { name, nwcUrl, relayUrl, walletPubkey, secret, saveConfig, clearConfig, enabled, isDefault, setIsDefault, getInfo, sendPayment } return ( {children} diff --git a/pages/settings/wallets/lnbits.js b/pages/settings/wallets/lnbits.js index 231c53f4d..5700a989f 100644 --- a/pages/settings/wallets/lnbits.js +++ b/pages/settings/wallets/lnbits.js @@ -1,5 +1,5 @@ import { getGetServerSideProps } from '../../../api/ssrApollo' -import { Form, ClientInput } from '../../../components/form' +import { Form, ClientInput, ClientCheckbox } from '../../../components/form' import { CenterLayout } from '../../../components/layout' import { WalletButtonBar, WalletCard } from '../../../components/wallet-card' import { lnbitsSchema } from '../../../lib/validate' @@ -10,7 +10,7 @@ import { useLNbits } from '../../../components/webln/lnbits' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) export default function LNbits () { - const { url, adminKey, saveConfig, clearConfig, enabled } = useLNbits() + const { url, adminKey, saveConfig, clearConfig, enabled, isDefault } = useLNbits() const toaster = useToast() const router = useRouter() @@ -21,7 +21,8 @@ export default function LNbits () { { @@ -49,6 +50,12 @@ export default function LNbits () { label='admin key' name='adminKey' /> + { try { diff --git a/pages/settings/wallets/nwc.js b/pages/settings/wallets/nwc.js index 5af5162f7..e5a9bf573 100644 --- a/pages/settings/wallets/nwc.js +++ b/pages/settings/wallets/nwc.js @@ -1,5 +1,5 @@ import { getGetServerSideProps } from '../../../api/ssrApollo' -import { Form, ClientInput } from '../../../components/form' +import { Form, ClientInput, ClientCheckbox } from '../../../components/form' import { CenterLayout } from '../../../components/layout' import { WalletButtonBar, WalletCard } from '../../../components/wallet-card' import { nwcSchema } from '../../../lib/validate' @@ -10,7 +10,7 @@ import { useNWC } from '../../../components/webln/nwc' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) export default function NWC () { - const { nwcUrl, saveConfig, clearConfig, enabled } = useNWC() + const { nwcUrl, saveConfig, clearConfig, enabled, isDefault } = useNWC() const toaster = useToast() const router = useRouter() @@ -20,7 +20,8 @@ export default function NWC () {
use Nostr Wallet Connect for zapping
{ @@ -41,6 +42,12 @@ export default function NWC () { required autoFocus /> + { try { From 998a6000a2c5324af7ac9bcdae11062b1b9ad910 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 8 Feb 2024 18:00:16 +0100 Subject: [PATCH 73/75] Update TODO comment about provider priority The list 'paymentMethods' is not used yet but is already implemented for future iterations. --- components/webln/index.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/components/webln/index.js b/components/webln/index.js index c18a43d6c..a4a96fc38 100644 --- a/components/webln/index.js +++ b/components/webln/index.js @@ -29,10 +29,11 @@ function RawWebLNProvider ({ children }) { const nwc = useNWC() const providers = [lnbits, nwc] - // order of payment methods depends on user preference: - // payment method at index 0 is default, if that one fails - // we try the remaining ones in order as fallbacks. - // -- TODO: implement fallback logic + // 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 = () => { @@ -77,7 +78,7 @@ function RawWebLNProvider ({ children }) { if (lnbits.isDefault) setDefaultPaymentMethod(lnbits) }, [lnbits.isDefault]) - // TODO: implement numeric provider priority + // 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) { From 7a55913cc65338f31337d308037cfad2f37207ff Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 8 Feb 2024 18:37:30 +0100 Subject: [PATCH 74/75] Add wallet security disclaimer --- components/banners.js | 17 +++++++++++++++++ pages/settings/wallets/lnbits.js | 2 ++ pages/settings/wallets/nwc.js | 2 ++ 3 files changed, 21 insertions(+) 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/pages/settings/wallets/lnbits.js b/pages/settings/wallets/lnbits.js index 5700a989f..7ea68b687 100644 --- a/pages/settings/wallets/lnbits.js +++ b/pages/settings/wallets/lnbits.js @@ -6,6 +6,7 @@ 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 }) @@ -18,6 +19,7 @@ export default function LNbits () {

lnbits

use lnbits for zapping
+

nwc

use Nostr Wallet Connect for zapping
+ Date: Thu, 8 Feb 2024 18:41:05 +0100 Subject: [PATCH 75/75] Update labels --- pages/settings/wallets/lnbits.js | 6 +++--- pages/settings/wallets/nwc.js | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pages/settings/wallets/lnbits.js b/pages/settings/wallets/lnbits.js index 7ea68b687..2d137d163 100644 --- a/pages/settings/wallets/lnbits.js +++ b/pages/settings/wallets/lnbits.js @@ -17,8 +17,8 @@ export default function LNbits () { return ( -

lnbits

-
use lnbits for zapping
+

LNbits

+
use LNbits for payments
-

nwc

-
use Nostr Wallet Connect for zapping
+

Nostr Wallet Connect

+
use Nostr Wallet Connect for payments