diff --git a/components/webln/index.js b/components/webln/index.js
new file mode 100644
index 000000000..a4a96fc38
--- /dev/null
+++ b/components/webln/index.js
@@ -0,0 +1,142 @@
+import { createContext, useContext, useEffect, useState } from 'react'
+import { LNbitsProvider, useLNbits } from './lnbits'
+import { NWCProvider, useNWC } from './nwc'
+import { useToast } from '../toast'
+import { gql, useMutation } from '@apollo/client'
+
+const WebLNContext = createContext({})
+const storageKey = 'webln:providers'
+
+const paymentMethodHook = (methods, { name, enabled }) => {
+ let newMethods
+ if (enabled) {
+ newMethods = methods.includes(name) ? methods : [...methods, name]
+ } else {
+ newMethods = methods.filter(m => m !== name)
+ }
+ savePaymentMethods(newMethods)
+ return newMethods
+}
+
+const savePaymentMethods = (methods) => {
+ window.localStorage.setItem(storageKey, JSON.stringify(methods))
+}
+
+function RawWebLNProvider ({ children }) {
+ // LNbits should only be used during development
+ // since it gives full wallet access on XSS
+ const lnbits = useLNbits()
+ const nwc = useNWC()
+ const providers = [lnbits, nwc]
+
+ // TODO: Order of payment methods depends on user preference.
+ // Payment method at index 0 should be default,
+ // if that one fails we try the remaining ones in order as fallbacks.
+ // We should be able to implement this via dragging of cards.
+ // This list should then match the order in which the (payment) cards are rendered.
+ // eslint-disable-next-line no-unused-vars
+ const [paymentMethods, setPaymentMethods] = useState([])
+ const loadPaymentMethods = () => {
+ const methods = window.localStorage.getItem(storageKey)
+ if (!methods) return
+ setPaymentMethods(JSON.parse(methods))
+ }
+ useEffect(loadPaymentMethods, [])
+
+ const toaster = useToast()
+ const [cancelInvoice] = useMutation(gql`
+ mutation cancelInvoice($hash: String!, $hmac: String!) {
+ cancelInvoice(hash: $hash, hmac: $hmac) {
+ id
+ }
+ }
+ `)
+
+ useEffect(() => {
+ setPaymentMethods(methods => paymentMethodHook(methods, nwc))
+ if (!nwc.enabled) nwc.setIsDefault(false)
+ }, [nwc.enabled])
+
+ useEffect(() => {
+ setPaymentMethods(methods => paymentMethodHook(methods, lnbits))
+ if (!lnbits.enabled) lnbits.setIsDefault(false)
+ }, [lnbits.enabled])
+
+ const setDefaultPaymentMethod = (provider) => {
+ for (const p of providers) {
+ if (p.name !== provider.name) {
+ p.setIsDefault(false)
+ }
+ }
+ }
+
+ useEffect(() => {
+ if (nwc.isDefault) setDefaultPaymentMethod(nwc)
+ }, [nwc.isDefault])
+
+ useEffect(() => {
+ if (lnbits.isDefault) setDefaultPaymentMethod(lnbits)
+ }, [lnbits.isDefault])
+
+ // TODO: implement numeric provider priority using paymentMethods list
+ // when we have more than two providers for sending
+ let provider = providers.filter(p => p.enabled && p.isDefault)[0]
+ if (!provider && providers.length > 0) {
+ // if no provider is the default, pick the first one and use that one as the default
+ provider = providers.filter(p => p.enabled)[0]
+ if (provider) {
+ provider.setIsDefault(true)
+ }
+ }
+
+ const sendPaymentWithToast = function ({ bolt11, hash, hmac }) {
+ let canceled = false
+ let removeToast = toaster.warning('payment pending', {
+ autohide: false,
+ onCancel: async () => {
+ try {
+ await cancelInvoice({ variables: { hash, hmac } })
+ canceled = true
+ toaster.warning('payment canceled')
+ removeToast = undefined
+ } catch (err) {
+ toaster.danger('failed to cancel payment')
+ }
+ }
+ })
+ return provider.sendPayment(bolt11)
+ .then(({ preimage }) => {
+ removeToast?.()
+ toaster.success('payment successful')
+ return { preimage }
+ }).catch((err) => {
+ if (canceled) return
+ removeToast?.()
+ const reason = err?.message?.toString().toLowerCase() || 'unknown reason'
+ toaster.danger(`payment failed: ${reason}`)
+ throw err
+ })
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function WebLNProvider ({ children }) {
+ return (
+
+
+
+ {children}
+
+
+
+ )
+}
+
+export function useWebLN () {
+ return useContext(WebLNContext)
+}
diff --git a/components/webln/lnbits.js b/components/webln/lnbits.js
new file mode 100644
index 000000000..5bf5cf207
--- /dev/null
+++ b/components/webln/lnbits.js
@@ -0,0 +1,166 @@
+import { createContext, useCallback, useContext, useEffect, useState } from 'react'
+
+// Reference: https://github.com/getAlby/bitcoin-connect/blob/v3.2.0-alpha/src/connectors/LnbitsConnector.ts
+
+const LNbitsContext = createContext()
+
+const getWallet = async (baseUrl, adminKey) => {
+ const url = baseUrl.replace(/\/+$/, '')
+ const path = '/api/v1/wallet'
+
+ const headers = new Headers()
+ headers.append('Accept', 'application/json')
+ headers.append('Content-Type', 'application/json')
+ headers.append('X-Api-Key', adminKey)
+
+ const res = await fetch(url + path, { method: 'GET', headers })
+ if (!res.ok) {
+ const errBody = await res.json()
+ throw new Error(errBody.detail)
+ }
+ const wallet = await res.json()
+ return wallet
+}
+
+const postPayment = async (baseUrl, adminKey, bolt11) => {
+ const url = baseUrl.replace(/\/+$/, '')
+ const path = '/api/v1/payments'
+
+ const headers = new Headers()
+ headers.append('Accept', 'application/json')
+ headers.append('Content-Type', 'application/json')
+ headers.append('X-Api-Key', adminKey)
+
+ const body = JSON.stringify({ bolt11, out: true })
+
+ const res = await fetch(url + path, { method: 'POST', headers, body })
+ if (!res.ok) {
+ const errBody = await res.json()
+ throw new Error(errBody.detail)
+ }
+ const payment = await res.json()
+ return payment
+}
+
+const getPayment = async (baseUrl, adminKey, paymentHash) => {
+ const url = baseUrl.replace(/\/+$/, '')
+ const path = `/api/v1/payments/${paymentHash}`
+
+ const headers = new Headers()
+ headers.append('Accept', 'application/json')
+ headers.append('Content-Type', 'application/json')
+ headers.append('X-Api-Key', adminKey)
+
+ const res = await fetch(url + path, { method: 'GET', headers })
+ if (!res.ok) {
+ const errBody = await res.json()
+ throw new Error(errBody.detail)
+ }
+ const payment = await res.json()
+ return payment
+}
+
+export function LNbitsProvider ({ children }) {
+ const [url, setUrl] = useState('')
+ const [adminKey, setAdminKey] = useState('')
+ const [enabled, setEnabled] = useState()
+ const [isDefault, setIsDefault] = useState()
+
+ const name = 'LNbits'
+ const storageKey = 'webln:provider:lnbits'
+
+ const getInfo = useCallback(async () => {
+ const response = await getWallet(url, adminKey)
+ return {
+ node: {
+ alias: response.name,
+ pubkey: ''
+ },
+ methods: [
+ 'getInfo',
+ 'getBalance',
+ 'sendPayment'
+ ],
+ version: '1.0',
+ supports: ['lightning']
+ }
+ }, [url, adminKey])
+
+ const sendPayment = useCallback(async (bolt11) => {
+ const response = await postPayment(url, adminKey, bolt11)
+ const checkResponse = await getPayment(url, adminKey, response.payment_hash)
+ if (!checkResponse.preimage) {
+ throw new Error('No preimage')
+ }
+ return { preimage: checkResponse.preimage }
+ }, [url, adminKey])
+
+ const loadConfig = useCallback(async () => {
+ const configStr = window.localStorage.getItem(storageKey)
+ if (!configStr) {
+ setEnabled(undefined)
+ return
+ }
+
+ const config = JSON.parse(configStr)
+
+ const { url, adminKey, isDefault } = config
+ setUrl(url)
+ setAdminKey(adminKey)
+ setIsDefault(isDefault)
+
+ try {
+ // validate config by trying to fetch wallet
+ await getWallet(url, adminKey)
+ setEnabled(true)
+ } catch (err) {
+ console.error('invalid LNbits config:', err)
+ setEnabled(false)
+ throw err
+ }
+ }, [])
+
+ const saveConfig = useCallback(async (config) => {
+ // immediately store config so it's not lost even if config is invalid
+ setUrl(config.url)
+ setAdminKey(config.adminKey)
+ setIsDefault(config.isDefault)
+
+ // XXX This is insecure, XSS vulns could lead to loss of funds!
+ // -> check how mutiny encrypts their wallet and/or check if we can leverage web workers
+ // https://thenewstack.io/leveraging-web-workers-to-safely-store-access-tokens/
+ window.localStorage.setItem(storageKey, JSON.stringify(config))
+
+ try {
+ // validate config by trying to fetch wallet
+ await getWallet(config.url, config.adminKey)
+ } catch (err) {
+ console.error('invalid LNbits config:', err)
+ setEnabled(false)
+ throw err
+ }
+ setEnabled(true)
+ }, [])
+
+ const clearConfig = useCallback(() => {
+ window.localStorage.removeItem(storageKey)
+ setUrl('')
+ setAdminKey('')
+ setEnabled(undefined)
+ }, [])
+
+ useEffect(() => {
+ loadConfig().catch(console.error)
+ }, [])
+
+ const value = { name, url, adminKey, saveConfig, clearConfig, enabled, isDefault, setIsDefault, getInfo, sendPayment }
+ return (
+
+ {children}
+
+ )
+}
+
+export function useLNbits () {
+ return useContext(LNbitsContext)
+}
diff --git a/components/webln/nwc.js b/components/webln/nwc.js
new file mode 100644
index 000000000..ac72209a1
--- /dev/null
+++ b/components/webln/nwc.js
@@ -0,0 +1,249 @@
+// https://github.com/getAlby/js-sdk/blob/master/src/webln/NostrWeblnProvider.ts
+
+import { createContext, useCallback, useContext, useEffect, useState } from 'react'
+import { Relay, finalizeEvent, nip04 } from 'nostr-tools'
+
+const NWCContext = createContext()
+
+export function NWCProvider ({ children }) {
+ const [nwcUrl, setNwcUrl] = useState('')
+ const [walletPubkey, setWalletPubkey] = useState()
+ const [relayUrl, setRelayUrl] = useState()
+ const [secret, setSecret] = useState()
+ const [enabled, setEnabled] = useState()
+ const [isDefault, setIsDefault] = useState()
+ const [relay, setRelay] = useState()
+
+ const name = 'NWC'
+ const storageKey = 'webln:provider:nwc'
+
+ const loadConfig = useCallback(async () => {
+ const configStr = window.localStorage.getItem(storageKey)
+ if (!configStr) {
+ setEnabled(undefined)
+ return
+ }
+
+ const config = JSON.parse(configStr)
+
+ const { nwcUrl, isDefault } = config
+ setNwcUrl(nwcUrl)
+ setIsDefault(isDefault)
+
+ const params = parseWalletConnectUrl(nwcUrl)
+ setRelayUrl(params.relayUrl)
+ setWalletPubkey(params.walletPubkey)
+ setSecret(params.secret)
+
+ try {
+ const supported = await validateParams(params)
+ setEnabled(supported.includes('pay_invoice'))
+ } catch (err) {
+ console.error('invalid NWC config:', err)
+ setEnabled(false)
+ throw err
+ }
+ }, [])
+
+ const saveConfig = useCallback(async (config) => {
+ // immediately store config so it's not lost even if config is invalid
+ const { nwcUrl, isDefault } = config
+ setNwcUrl(nwcUrl)
+ setIsDefault(isDefault)
+ if (!nwcUrl) {
+ setEnabled(undefined)
+ return
+ }
+
+ const params = parseWalletConnectUrl(nwcUrl)
+ setRelayUrl(params.relayUrl)
+ setWalletPubkey(params.walletPubkey)
+ setSecret(params.secret)
+
+ // XXX Even though NWC allows to configure budget,
+ // this is definitely not ideal from a security perspective.
+ window.localStorage.setItem(storageKey, JSON.stringify(config))
+
+ try {
+ const supported = await validateParams(params)
+ setEnabled(supported.includes('pay_invoice'))
+ } catch (err) {
+ console.error('invalid NWC config:', err)
+ setEnabled(false)
+ throw err
+ }
+ }, [])
+
+ const clearConfig = useCallback(() => {
+ window.localStorage.removeItem(storageKey)
+ setNwcUrl('')
+ setRelayUrl(undefined)
+ setWalletPubkey(undefined)
+ setSecret(undefined)
+ setEnabled(undefined)
+ }, [])
+
+ useEffect(() => {
+ let relay
+ (async function () {
+ if (relayUrl) {
+ relay = await Relay.connect(relayUrl)
+ setRelay(relay)
+ }
+ })().catch((err) => {
+ console.error(err)
+ setRelay(null)
+ })
+ return () => {
+ relay?.close()
+ setRelay(null)
+ }
+ }, [relayUrl])
+
+ const sendPayment = useCallback((bolt11) => {
+ return new Promise(function (resolve, reject) {
+ (async function () {
+ // XXX set this to mock NWC relays
+ const MOCK_NWC_RELAY = false
+
+ // timeout since NWC is async (user needs to confirm payment in wallet)
+ // timeout is same as invoice expiry
+ const timeout = MOCK_NWC_RELAY ? 3000 : 180_000
+ let timer
+ const resetTimer = () => {
+ clearTimeout(timer)
+ timer = setTimeout(() => {
+ sub?.close()
+ if (MOCK_NWC_RELAY) {
+ const heads = Math.random() < 0.5
+ if (heads) {
+ return resolve({ preimage: null })
+ }
+ return reject(new Error('mock error'))
+ }
+ return reject(new Error('timeout'))
+ }, timeout)
+ }
+ if (MOCK_NWC_RELAY) return resetTimer()
+
+ const payload = {
+ method: 'pay_invoice',
+ params: { invoice: bolt11 }
+ }
+ const content = await nip04.encrypt(secret, walletPubkey, JSON.stringify(payload))
+ const request = finalizeEvent({
+ kind: 23194,
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [['p', walletPubkey]],
+ content
+ }, secret)
+ await relay.publish(request)
+ resetTimer()
+
+ const filter = {
+ kinds: [23195],
+ authors: [walletPubkey],
+ '#e': [request.id]
+ }
+ const sub = relay.subscribe([filter], {
+ async onevent (response) {
+ resetTimer()
+ try {
+ const content = JSON.parse(await nip04.decrypt(secret, walletPubkey, response.content))
+ if (content.error) return reject(new Error(content.error.message))
+ if (content.result) return resolve({ preimage: content.result.preimage })
+ } catch (err) {
+ return reject(err)
+ } finally {
+ clearTimeout(timer)
+ sub.close()
+ }
+ },
+ onclose (reason) {
+ clearTimeout(timer)
+ reject(new Error(reason))
+ }
+ })
+ })().catch(reject)
+ })
+ }, [relay, walletPubkey, secret])
+
+ const getInfo = useCallback(() => getInfoWithRelay(relay, walletPubkey), [relay, walletPubkey])
+
+ useEffect(() => {
+ loadConfig().catch(console.error)
+ }, [])
+
+ const value = { name, nwcUrl, relayUrl, walletPubkey, secret, saveConfig, clearConfig, enabled, isDefault, setIsDefault, getInfo, sendPayment }
+ return (
+
+ {children}
+
+ )
+}
+
+export function useNWC () {
+ return useContext(NWCContext)
+}
+
+async function validateParams ({ relayUrl, walletPubkey, secret }) {
+ let infoRelay
+ try {
+ // validate connection by fetching info event
+ infoRelay = await Relay.connect(relayUrl)
+ return await getInfoWithRelay(infoRelay, walletPubkey)
+ } finally {
+ infoRelay?.close()
+ }
+}
+
+async function getInfoWithRelay (relay, walletPubkey) {
+ return await new Promise((resolve, reject) => {
+ const timeout = 5000
+ const timer = setTimeout(() => {
+ reject(new Error('timeout waiting for response'))
+ sub?.close()
+ }, timeout)
+
+ const sub = relay.subscribe([
+ {
+ kinds: [13194],
+ authors: [walletPubkey]
+ }
+ ], {
+ onevent (event) {
+ clearTimeout(timer)
+ const supported = event.content.split()
+ resolve(supported)
+ sub.close()
+ },
+ onclose (reason) {
+ clearTimeout(timer)
+ reject(new Error(reason))
+ },
+ oneose () {
+ clearTimeout(timer)
+ reject(new Error('info event not found'))
+ }
+ })
+ })
+}
+
+function parseWalletConnectUrl (walletConnectUrl) {
+ walletConnectUrl = walletConnectUrl
+ .replace('nostrwalletconnect://', 'http://')
+ .replace('nostr+walletconnect://', 'http://') // makes it possible to parse with URL in the different environments (browser/node/...)
+
+ const url = new URL(walletConnectUrl)
+ const params = {}
+ params.walletPubkey = url.host
+ const secret = url.searchParams.get('secret')
+ const relayUrl = url.searchParams.get('relay')
+ if (secret) {
+ params.secret = secret
+ }
+ if (relayUrl) {
+ params.relayUrl = relayUrl
+ }
+ return params
+}
diff --git a/docs/attach-lnbits.md b/docs/attach-lnbits.md
new file mode 100644
index 000000000..b64924ead
--- /dev/null
+++ b/docs/attach-lnbits.md
@@ -0,0 +1,81 @@
+# attach lnbits
+
+To test sending from an attached wallet, it's easiest to use [lnbits](https://lnbits.com/) hooked up to a [local lnd node](./local-lnd.md) in your regtest network.
+
+This will attempt to walk you through setting up lnbits with docker and connecting it to your local lnd node.
+
+🚨 this a dev guide. do not use this guide for real funds 🚨
+
+From [this guide](https://docs.lnbits.org/guide/installation.html#option-3-docker):
+
+## 1. pre-configuration
+
+Create a directory for lnbits, get the sample environment file, and create a shared data directory for lnbits to use:
+
+```bash
+mkdir lnbits
+cd lnbits
+wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env
+mkdir data
+```
+
+## 2. configure
+
+To configure lnbits to use a [local lnd node](./local-lnd.md) in your regtest network, go to [polar](https://lightningpolar.com/) and click on the LND node you want to use as a funding source. Then click on `Connect`.
+
+In the `Connect` tab, click the `File paths` tab and copy the `TLS cert` and `Admin macaroon` files to the `data` directory you created earlier.
+
+```bash
+cp /path/to/tls.cert /path/to/admin.macaroon data/
+```
+
+Then, open the `.env` file you created and override the following values:
+
+```bash
+LNBITS_ADMIN_UI=true
+LNBITS_BACKEND_WALLET_CLASS=LndWallet
+LND_GRPC_ENDPOINT=host.docker.internal
+LND_GRPC_PORT=${Port from the polar connect page}
+LND_GRPC_CERT=data/tls.cert
+LND_GRPC_MACAROON=data/admin.macaroon
+```
+
+## 2. Install and run lnbits
+
+Pull the latest image:
+
+```bash
+docker pull lnbitsdocker/lnbits-legend
+docker run --detach --publish 5001:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbitsdocker/lnbits-legend
+```
+
+Note: we make lnbits available on the host's port 5001 here (on Mac, 5000 is used by AirPlay), but you can change that to whatever you want.
+
+## 3. Accessing the admin wallet
+
+By enabling the [Admin UI](https://docs.lnbits.org/guide/admin_ui.html), lnbits creates a so called super_user. Get this super_user id by running:
+
+```bash
+cat data/.super_user
+```
+
+Open your browser and go to `http://localhost:5001/wallet?usr=${super_user id from above}`. LNBits will redirect you to a default wallet we will use called `LNBits wallet`.
+
+## 4. Fund the wallet
+
+To fund `LNBits wallet`, click the `+` next the wallet balance. Enter the number of sats you want to credit the wallet and hit enter.
+
+## 5. Attach the wallet to stackernews
+
+Open up your local stackernews, go to `http://localhost:3000/settings/wallets` and click on `attach` in the `lnbits` card.
+
+In the form, fill in `lnbits url` with `http://localhost:5001`.
+
+Back in lnbits click on `API Docs` in the right pane. Copy the Admin key and paste it into the `admin key` field in the form.
+
+Click `attach` and you should be good to go.
+
+## Debugging
+
+- you can view lnbits logs with `docker logs lnbits` or in `data/logs/` in the `data` directory you created earlier
+- with the [Admin UI](https://docs.lnbits.org/guide/admin_ui.html), you can modify LNBits in the GUI by clicking `Server` in left pane
\ No newline at end of file
diff --git a/lib/validate.js b/lib/validate.js
index 30d8dc0fc..2526b4d5f 100644
--- a/lib/validate.js
+++ b/lib/validate.js
@@ -421,6 +421,19 @@ export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) =>
return accum
}, {})))
+export const lnbitsSchema = object({
+ url: process.env.NODE_ENV === 'development'
+ ? string().or(
+ [string().matches(/^(http:\/\/)?localhost:\d+$/), string().url()],
+ 'invalid url').required('required').trim()
+ : string().url().required('required').trim(),
+ adminKey: string().length(32)
+})
+
+export const nwcSchema = object({
+ nwcUrl: string().required('required').trim().matches(/^nostr\+walletconnect:/)
+})
+
export const bioSchema = object({
bio: string().required('required').trim()
})
diff --git a/package-lock.json b/package-lock.json
index 6984fb928..2360d1ae2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -51,6 +51,7 @@
"node-s3-url-encode": "^0.0.4",
"nodemailer": "^6.9.6",
"nostr": "^0.2.8",
+ "nostr-tools": "^2.1.5",
"nprogress": "^0.2.0",
"opentimestamps": "^0.4.9",
"page-metadata-parser": "^1.1.4",
@@ -2654,6 +2655,14 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/@noble/ciphers": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.2.0.tgz",
+ "integrity": "sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw==",
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@noble/curves": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
@@ -2961,6 +2970,64 @@
"rollup": "^1.20.0||^2.0.0"
}
},
+ "node_modules/@scure/base": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz",
+ "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ ]
+ },
+ "node_modules/@scure/bip32": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
+ "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
+ "dependencies": {
+ "@noble/curves": "~1.1.0",
+ "@noble/hashes": "~1.3.1",
+ "@scure/base": "~1.1.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@scure/bip32/node_modules/@noble/curves": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
+ "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
+ "dependencies": {
+ "@noble/hashes": "1.3.1"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@scure/bip32/node_modules/@noble/hashes": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
+ "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@scure/bip39": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
+ "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
+ "dependencies": {
+ "@noble/hashes": "~1.3.0",
+ "@scure/base": "~1.1.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@sindresorhus/is": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
@@ -10315,6 +10382,47 @@
"ws": "^8.8.1"
}
},
+ "node_modules/nostr-tools": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.1.5.tgz",
+ "integrity": "sha512-Gug/j54YGQ0ewB09dZW3mS9qfXWFlcOQMlyb1MmqQsuNO/95mfNOQSBi+jZ61O++Y+jG99SzAUPFLopUsKf0MA==",
+ "dependencies": {
+ "@noble/ciphers": "0.2.0",
+ "@noble/curves": "1.2.0",
+ "@noble/hashes": "1.3.1",
+ "@scure/base": "1.1.1",
+ "@scure/bip32": "1.3.1",
+ "@scure/bip39": "1.2.1"
+ },
+ "optionalDependencies": {
+ "nostr-wasm": "v0.1.0"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/nostr-tools/node_modules/@noble/hashes": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
+ "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/nostr-wasm": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz",
+ "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==",
+ "optional": true
+ },
"node_modules/nprogress": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz",
diff --git a/package.json b/package.json
index c4b60955e..c4bae7d72 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
"node-s3-url-encode": "^0.0.4",
"nodemailer": "^6.9.6",
"nostr": "^0.2.8",
+ "nostr-tools": "^2.1.5",
"nprogress": "^0.2.0",
"opentimestamps": "^0.4.9",
"page-metadata-parser": "^1.1.4",
diff --git a/pages/_app.js b/pages/_app.js
index 64129b586..b537811dd 100644
--- a/pages/_app.js
+++ b/pages/_app.js
@@ -18,6 +18,7 @@ import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { LoggerProvider } from '../components/logger'
import { ChainFeeProvider } from '../components/chain-fee.js'
+import { WebLNProvider } from '../components/webln'
import dynamic from 'next/dynamic'
const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false })
@@ -98,16 +99,18 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pages/settings/wallets/index.js b/pages/settings/wallets/index.js
index bcb34d845..a03b5589b 100644
--- a/pages/settings/wallets/index.js
+++ b/pages/settings/wallets/index.js
@@ -3,6 +3,8 @@ import Layout from '../../../components/layout'
import styles from '../../../styles/wallet.module.css'
import { WalletCard } from '../../../components/wallet-card'
import { LightningAddressWalletCard } from './lightning-address'
+import { LNbitsCard } from './lnbits'
+import { NWCCard } from './nwc'
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
@@ -14,6 +16,8 @@ export default function Wallet () {
attach wallets to supplement your SN wallet
+
+
diff --git a/pages/settings/wallets/lnbits.js b/pages/settings/wallets/lnbits.js
new file mode 100644
index 000000000..2d137d163
--- /dev/null
+++ b/pages/settings/wallets/lnbits.js
@@ -0,0 +1,88 @@
+import { getGetServerSideProps } from '../../../api/ssrApollo'
+import { Form, ClientInput, ClientCheckbox } from '../../../components/form'
+import { CenterLayout } from '../../../components/layout'
+import { WalletButtonBar, WalletCard } from '../../../components/wallet-card'
+import { lnbitsSchema } from '../../../lib/validate'
+import { useToast } from '../../../components/toast'
+import { useRouter } from 'next/router'
+import { useLNbits } from '../../../components/webln/lnbits'
+import { WalletSecurityBanner } from '../../../components/banners'
+
+export const getServerSideProps = getGetServerSideProps({ authRequired: true })
+
+export default function LNbits () {
+ const { url, adminKey, saveConfig, clearConfig, enabled, isDefault } = useLNbits()
+ const toaster = useToast()
+ const router = useRouter()
+
+ return (
+
+ LNbits
+ use LNbits for payments
+
+
+
+ )
+}
+
+export function LNbitsCard () {
+ const { enabled } = useLNbits()
+ return (
+
+ )
+}
diff --git a/pages/settings/wallets/nwc.js b/pages/settings/wallets/nwc.js
new file mode 100644
index 000000000..059bedd10
--- /dev/null
+++ b/pages/settings/wallets/nwc.js
@@ -0,0 +1,80 @@
+import { getGetServerSideProps } from '../../../api/ssrApollo'
+import { Form, ClientInput, ClientCheckbox } from '../../../components/form'
+import { CenterLayout } from '../../../components/layout'
+import { WalletButtonBar, WalletCard } from '../../../components/wallet-card'
+import { nwcSchema } from '../../../lib/validate'
+import { useToast } from '../../../components/toast'
+import { useRouter } from 'next/router'
+import { useNWC } from '../../../components/webln/nwc'
+import { WalletSecurityBanner } from '../../../components/banners'
+
+export const getServerSideProps = getGetServerSideProps({ authRequired: true })
+
+export default function NWC () {
+ const { nwcUrl, saveConfig, clearConfig, enabled, isDefault } = useNWC()
+ const toaster = useToast()
+ const router = useRouter()
+
+ return (
+
+ Nostr Wallet Connect
+ use Nostr Wallet Connect for payments
+
+
+
+ )
+}
+
+export function NWCCard () {
+ const { enabled } = useNWC()
+ return (
+
+ )
+}
diff --git a/styles/wallet.module.css b/styles/wallet.module.css
index d72bea30a..a9bc1e63e 100644
--- a/styles/wallet.module.css
+++ b/styles/wallet.module.css
@@ -48,6 +48,13 @@
filter: drop-shadow(0 0 2px #20c997);
}
+.indicator.error {
+ color: var(--bs-red) !important;
+ background-color: var(--bs-red) !important;
+ border: 1px solid var(--bs-danger);
+ filter: drop-shadow(0 0 2px #c92020);
+}
+
.indicator.disabled {
color: var(--theme-toolbarHover) !important;
background-color: var(--theme-toolbarHover) !important;