Expose WebLN interface via React Context (#749)
* Add LNbits card * Save LNbits Provider in WebLN context * Check LNbits connection on save * refactor: put LNbitsProvider into own file * Pay invoices using WebLN provider from context * Remove deprecated FIXME * Try WebLN provider first * Fix unhandled promise rejection * Fix this in sendPayment * 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. * Bold strike on WebLN zap If lightning strike animation is disabled, toaster will be used. * Rename undo variable to amount * Fix zap undo * Add NWC card * Attempt to check NWC connection using info event * Fix NaN on zap Third argument of update is reserved for context * Fix TypeError in catch of QR code * Add basic NWC payments * Wrap LNbits getInfo with try/catch * EOSE is enough to check NWC connection * refactor: Wrap WebLN providers into own context I should have done this earlier * Show red indicator on error * Fix useEffect return value * 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. * Use p tag in NWC request * Add comment about required filter field * Aesthetic changes to NWC sendPayment * Add TODO about receipt verification * Fix WebLN attempted again after error * Fix undefined name * Add code to mock NWC relay * Revert "Bold strike on WebLN zap" This reverts commit a9eb27daec0cd2ef30b56294b05e0056fb5b4184. * Fix update undo * Fix lightning strike before payment * 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 * Fix invoice poll using stale value from cache * Remove unnecessary if * Make sure that pay_invoice is declared as supported * Check if WebLN provider is enabled before calling sendPayment * 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. * Fix cache undo update * Fix no cache update after QR payment * refactor: Use fragments to undo cache updates * Remove console.log * Small changes to NWC relay mocking * Return SendPaymentResponse See https://www.webln.guide/building-lightning-apps/webln-reference/webln.sendpayment * Also undo cache update on retry failure * Disable NWC mocking * 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 """ * Remove comment since only relevant for blastr (mutiny relay) * Remove TODO * Fix duplicate cache update * Fix QR modal not closed after payment * Ignore lnbits variable unused * Use single relay connection for all NWC events * Fix missing timer and subscription cleanup * 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 * Fix switch from controlled to uncontrolled input * Show 'configure' on error * Use budgetable instead of async * Remove EOSE listener Only nostr.mutinywallet.com didn't respond with info events due to implementation-specific reasons. This is no longer the case. * Use invoice expiry for NWC timeout I don't think there was a specific reason why I used 60 seconds initially. * Validate LNbits config on save * Validate NWC config on save * 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. * 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. * Fix formik bag lost * Use payment instead of zap in toast * autoscale capture svc by response time * docs and changes for testing lnbits locally * 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. * Allow setting of default payment provider * Update TODO comment about provider priority The list 'paymentMethods' is not used yet but is already implemented for future iterations. * Add wallet security disclaimer * Update labels --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
This commit is contained in:
parent
6adb57c8ec
commit
310011f05d
|
@ -20,6 +20,7 @@ node_modules/
|
|||
.DS_Store
|
||||
*.pem
|
||||
/*.sql
|
||||
lnbits/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
|
|
@ -88,3 +88,20 @@ export function WalletLimitBanner () {
|
|||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
export function WalletSecurityBanner () {
|
||||
return (
|
||||
<Alert className={styles.banner} key='info' variant='warning'>
|
||||
<Alert.Heading>
|
||||
Wallet Security Disclaimer
|
||||
</Alert.Heading>
|
||||
<p className='mb-1'>
|
||||
Your wallet's credentials are stored in the browser and never go to the server.<br />
|
||||
However, you should definitely <strong>set a budget in your wallet</strong>.
|
||||
</p>
|
||||
<p>
|
||||
Also, for the time being, you will have to reenter your credentials on other devices.
|
||||
</p>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -961,3 +961,22 @@ export function DatePicker ({ fromName, toName, noForm, onChange, when, from, to
|
|||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Client (Component) {
|
||||
return ({ initialValue, ...props }) => {
|
||||
// This component can be used for Formik fields
|
||||
// where the initial value is not available on first render.
|
||||
// Example: value is stored in localStorage which is fetched
|
||||
// after first render using an useEffect hook.
|
||||
const [,, helpers] = useField(props)
|
||||
|
||||
useEffect(() => {
|
||||
helpers.setValue(initialValue)
|
||||
}, [initialValue])
|
||||
|
||||
return <Component {...props} />
|
||||
}
|
||||
}
|
||||
|
||||
export const ClientInput = Client(Input)
|
||||
export const ClientCheckbox = Client(Checkbox)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useMutation, useQuery } from '@apollo/client'
|
||||
import { useApolloClient, useMutation, useQuery } from '@apollo/client'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { gql } from 'graphql-tag'
|
||||
import { numWithUnits } from '../lib/format'
|
||||
|
@ -12,13 +12,17 @@ import { useShowModal } from './modal'
|
|||
import Countdown from './countdown'
|
||||
import PayerData from './payer-data'
|
||||
import Bolt11Info from './bolt11-info'
|
||||
import { useWebLN } from './webln'
|
||||
|
||||
export function Invoice ({ invoice, modal, onPayment, info, successVerb }) {
|
||||
export function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn }) {
|
||||
const [expired, setExpired] = useState(new Date(invoice.expiredAt) <= new Date())
|
||||
|
||||
// if webLn was not passed, use true by default
|
||||
if (webLn === undefined) webLn = true
|
||||
|
||||
let variant = 'default'
|
||||
let status = 'waiting for you'
|
||||
let webLn = true
|
||||
|
||||
if (invoice.cancelled) {
|
||||
variant = 'failed'
|
||||
status = 'cancelled'
|
||||
|
@ -118,7 +122,7 @@ const JITInvoice = ({ invoice: { id, hash, hmac, expiresAt }, onPayment, onCance
|
|||
|
||||
return (
|
||||
<>
|
||||
<Invoice invoice={data.invoice} modal onPayment={onPayment} successVerb='received' />
|
||||
<Invoice invoice={data.invoice} modal onPayment={onPayment} successVerb='received' webLn={false} />
|
||||
{retry
|
||||
? (
|
||||
<>
|
||||
|
@ -161,6 +165,7 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
|
|||
mutation createInvoice($amount: Int!) {
|
||||
createInvoice(amount: $amount, hodlInvoice: true, expireSecs: 180) {
|
||||
id
|
||||
bolt11
|
||||
hash
|
||||
hmac
|
||||
expiresAt
|
||||
|
@ -175,8 +180,13 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
|
|||
`)
|
||||
|
||||
const showModal = useShowModal()
|
||||
const provider = useWebLN()
|
||||
const client = useApolloClient()
|
||||
const pollInvoice = (id) => client.query({ query: INVOICE, fetchPolicy: 'no-cache', variables: { id } })
|
||||
|
||||
const onSubmitWrapper = useCallback(async ({ cost, ...formValues }, ...submitArgs) => {
|
||||
const onSubmitWrapper = useCallback(async (
|
||||
{ cost, ...formValues },
|
||||
{ variables, optimisticResponse, update, ...submitArgs }) => {
|
||||
// some actions require a session
|
||||
if (!me && options.requireSession) {
|
||||
throw new Error('you must be logged in')
|
||||
|
@ -189,7 +199,9 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
|
|||
// attempt action for the first time
|
||||
if (!cost || (me && !options.forceInvoice)) {
|
||||
try {
|
||||
return await onSubmit(formValues, ...submitArgs)
|
||||
const insufficientFunds = me?.privates.sats < cost
|
||||
return await onSubmit(formValues,
|
||||
{ ...submitArgs, variables, optimisticsResponse: insufficientFunds ? null : optimisticResponse })
|
||||
} catch (error) {
|
||||
if (!payOrLoginError(error) || !cost) {
|
||||
// can't handle error here - bail
|
||||
|
@ -205,27 +217,52 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
|
|||
}
|
||||
const inv = data.createInvoice
|
||||
|
||||
// If this is a zap, we need to manually be optimistic to have a consistent
|
||||
// UX across custodial and WebLN zaps since WebLN zaps don't call GraphQL
|
||||
// mutations which implement optimistic responses natively.
|
||||
// Therefore, we check if this is a zap and then wrap the WebLN payment logic
|
||||
// with manual cache update calls.
|
||||
const itemId = optimisticResponse?.act?.id
|
||||
const isZap = !!itemId
|
||||
let _update
|
||||
if (isZap && update) {
|
||||
_update = () => {
|
||||
const fragment = {
|
||||
id: `Item:${itemId}`,
|
||||
fragment: gql`
|
||||
fragment ItemMeSats on Item {
|
||||
sats
|
||||
meSats
|
||||
}
|
||||
`
|
||||
}
|
||||
const item = client.cache.readFragment(fragment)
|
||||
update(client.cache, { data: optimisticResponse })
|
||||
// undo function
|
||||
return () => client.cache.writeFragment({ ...fragment, data: item })
|
||||
}
|
||||
}
|
||||
|
||||
// wait until invoice is paid or modal is closed
|
||||
let modalClose
|
||||
await new Promise((resolve, reject) => {
|
||||
showModal(onClose => {
|
||||
modalClose = onClose
|
||||
return (
|
||||
<JITInvoice
|
||||
invoice={inv}
|
||||
onPayment={resolve}
|
||||
/>
|
||||
)
|
||||
}, { keepOpen: true, onClose: reject })
|
||||
const { modalOnClose, webLn, gqlCacheUpdateUndo } = await waitForPayment({
|
||||
invoice: inv,
|
||||
showModal,
|
||||
provider,
|
||||
pollInvoice,
|
||||
gqlCacheUpdate: _update
|
||||
})
|
||||
|
||||
const retry = () => onSubmit({ hash: inv.hash, hmac: inv.hmac, ...formValues }, ...submitArgs)
|
||||
const retry = () => onSubmit(
|
||||
{ hash: inv.hash, hmac: inv.hmac, ...formValues },
|
||||
// unset update function since we already ran an cache update if we paid using WebLN
|
||||
{ ...submitArgs, variables, update: webLn ? null : undefined })
|
||||
// first retry
|
||||
try {
|
||||
const ret = await retry()
|
||||
modalClose()
|
||||
modalOnClose?.()
|
||||
return ret
|
||||
} catch (error) {
|
||||
gqlCacheUpdateUndo?.()
|
||||
console.error('retry error:', error)
|
||||
}
|
||||
|
||||
|
@ -245,16 +282,91 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => {
|
|||
}}
|
||||
onRetry={async () => {
|
||||
resolve(await retry())
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}, { keepOpen: true, onClose: cancelAndReject })
|
||||
})
|
||||
}, [onSubmit, createInvoice, !!me])
|
||||
}, [onSubmit, provider, createInvoice, !!me])
|
||||
|
||||
return onSubmitWrapper
|
||||
}
|
||||
|
||||
const INVOICE_CANCELED_ERROR = 'invoice was canceled'
|
||||
const waitForPayment = async ({ invoice, showModal, provider, pollInvoice, gqlCacheUpdate }) => {
|
||||
if (provider.enabled) {
|
||||
try {
|
||||
return await waitForWebLNPayment({ provider, invoice, pollInvoice, gqlCacheUpdate })
|
||||
} catch (err) {
|
||||
const INVOICE_CANCELED_ERROR = 'invoice was canceled'
|
||||
// check for errors which mean that QR code will also fail
|
||||
if (err.message === INVOICE_CANCELED_ERROR) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// QR code as fallback
|
||||
return await new Promise((resolve, reject) => {
|
||||
showModal(onClose => {
|
||||
return (
|
||||
<JITInvoice
|
||||
invoice={invoice}
|
||||
onPayment={() => resolve({ modalOnClose: onClose })}
|
||||
/>
|
||||
)
|
||||
}, { keepOpen: true, onClose: reject })
|
||||
})
|
||||
}
|
||||
|
||||
const waitForWebLNPayment = async ({ provider, invoice, pollInvoice, gqlCacheUpdate }) => {
|
||||
let undoUpdate
|
||||
try {
|
||||
// try WebLN provider first
|
||||
return await new Promise((resolve, reject) => {
|
||||
// be optimistic and pretend zap was already successful for consistent zapping UX
|
||||
undoUpdate = gqlCacheUpdate?.()
|
||||
// can't use await here since we might be paying HODL invoices
|
||||
// and sendPaymentAsync is not supported yet.
|
||||
// see https://www.webln.guide/building-lightning-apps/webln-reference/webln.sendpaymentasync
|
||||
provider.sendPayment(invoice)
|
||||
// WebLN payment will never resolve here for HODL invoices
|
||||
// since they only get resolved after settlement which can't happen here
|
||||
.then(() => resolve({ webLn: true, gqlCacheUpdateUndo: undoUpdate }))
|
||||
.catch(err => {
|
||||
clearInterval(interval)
|
||||
reject(err)
|
||||
})
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const { data, error } = await pollInvoice(invoice.id)
|
||||
if (error) {
|
||||
clearInterval(interval)
|
||||
return reject(error)
|
||||
}
|
||||
const { invoice: inv } = data
|
||||
if (inv.isHeld && inv.satsReceived) {
|
||||
clearInterval(interval)
|
||||
resolve({ webLn: true, gqlCacheUpdateUndo: undoUpdate })
|
||||
}
|
||||
if (inv.cancelled) {
|
||||
clearInterval(interval)
|
||||
reject(new Error(INVOICE_CANCELED_ERROR))
|
||||
}
|
||||
} catch (err) {
|
||||
clearInterval(interval)
|
||||
reject(err)
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
} catch (err) {
|
||||
undoUpdate?.()
|
||||
console.error('WebLN payment failed:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export const useInvoiceModal = (onPayment, deps) => {
|
||||
const onPaymentMemo = useCallback(onPayment, deps)
|
||||
return useInvoiceable(onPaymentMemo, { replaceModal: true })
|
||||
|
|
|
@ -234,9 +234,9 @@ export function useZap () {
|
|||
const strike = useLightning()
|
||||
const [act] = useAct()
|
||||
|
||||
const showInvoiceModal = useInvoiceModal(
|
||||
async ({ hash, hmac }, { variables }) => {
|
||||
await act({ variables: { ...variables, hash, hmac } })
|
||||
const invoiceableAct = useInvoiceModal(
|
||||
async ({ hash, hmac }, { variables, ...apolloArgs }) => {
|
||||
await act({ variables: { ...variables, hash, hmac }, ...apolloArgs })
|
||||
strike()
|
||||
}, [act, strike])
|
||||
|
||||
|
@ -254,22 +254,22 @@ export function useZap () {
|
|||
}
|
||||
|
||||
const variables = { id: item.id, sats, act: 'TIP' }
|
||||
const insufficientFunds = me?.privates.sats < sats
|
||||
const optimisticResponse = { act: { path: item.path, ...variables } }
|
||||
try {
|
||||
await zap({
|
||||
variables,
|
||||
optimisticResponse: {
|
||||
act: {
|
||||
path: item.path,
|
||||
...variables
|
||||
}
|
||||
}
|
||||
})
|
||||
if (!insufficientFunds) strike()
|
||||
await zap({ variables, optimisticResponse: insufficientFunds ? null : optimisticResponse })
|
||||
} catch (error) {
|
||||
if (payOrLoginError(error)) {
|
||||
// call non-idempotent version
|
||||
const amount = sats - meSats
|
||||
optimisticResponse.act.amount = amount
|
||||
try {
|
||||
await showInvoiceModal({ amount }, { variables: { ...variables, sats: amount } })
|
||||
await invoiceableAct({ amount }, {
|
||||
variables: { ...variables, sats: amount },
|
||||
optimisticResponse,
|
||||
update
|
||||
})
|
||||
} catch (error) {}
|
||||
return
|
||||
}
|
||||
|
|
|
@ -1,20 +1,24 @@
|
|||
import QRCode from 'qrcode.react'
|
||||
import { CopyInput, InputSkeleton } from './form'
|
||||
import InvoiceStatus from './invoice-status'
|
||||
import { requestProvider } from 'webln'
|
||||
import { useEffect } from 'react'
|
||||
import { useWebLN } from './webln'
|
||||
import { useToast } from './toast'
|
||||
|
||||
export default function Qr ({ asIs, value, webLn, statusVariant, description, status }) {
|
||||
const qrValue = asIs ? value : 'lightning:' + value.toUpperCase()
|
||||
|
||||
const provider = useWebLN()
|
||||
const toaster = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
async function effect () {
|
||||
if (webLn) {
|
||||
try {
|
||||
const provider = await requestProvider()
|
||||
await provider.sendPayment(value)
|
||||
} catch (e) {
|
||||
console.log(e.message)
|
||||
console.log(e?.message)
|
||||
toaster.danger(`${provider.name}: ${e?.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 => <ItemAct onClose={onClose} itemId={item.id} act={act} />)
|
||||
|
|
|
@ -7,9 +7,10 @@ import CancelButton from './cancel-button'
|
|||
import { SubmitButton } from './form'
|
||||
|
||||
export function WalletCard ({ title, badges, provider, enabled }) {
|
||||
const isConfigured = enabled === true || enabled === false
|
||||
return (
|
||||
<Card className={styles.card}>
|
||||
<div className={`${styles.indicator} ${enabled ? styles.success : styles.disabled}`} />
|
||||
<div className={`${styles.indicator} ${enabled === true ? styles.success : enabled === false ? styles.error : styles.disabled}`} />
|
||||
<Card.Body>
|
||||
<Card.Title>{title}</Card.Title>
|
||||
<Card.Subtitle className='mt-2'>
|
||||
|
@ -23,7 +24,7 @@ export function WalletCard ({ title, badges, provider, enabled }) {
|
|||
{provider &&
|
||||
<Link href={`/settings/wallets/${provider}`}>
|
||||
<Card.Footer className={styles.attach}>
|
||||
{enabled
|
||||
{isConfigured
|
||||
? <>configure<Gear width={14} height={14} /></>
|
||||
: <>attach<Plug width={14} height={14} /></>}
|
||||
</Card.Footer>
|
||||
|
@ -40,7 +41,7 @@ export function WalletButtonBar ({
|
|||
return (
|
||||
<div className={`mt-3 ${className}`}>
|
||||
<div className='d-flex justify-content-between'>
|
||||
{enabled &&
|
||||
{enabled !== undefined &&
|
||||
<Button onClick={onDelete} variant='grey-medium'>{deleteText}</Button>}
|
||||
{children}
|
||||
<div className='d-flex align-items-center ms-auto'>
|
||||
|
|
|
@ -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 (
|
||||
<WebLNContext.Provider value={{ ...provider, sendPayment: sendPaymentWithToast }}>
|
||||
{children}
|
||||
</WebLNContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function WebLNProvider ({ children }) {
|
||||
return (
|
||||
<LNbitsProvider>
|
||||
<NWCProvider>
|
||||
<RawWebLNProvider>
|
||||
{children}
|
||||
</RawWebLNProvider>
|
||||
</NWCProvider>
|
||||
</LNbitsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useWebLN () {
|
||||
return useContext(WebLNContext)
|
||||
}
|
|
@ -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 (
|
||||
<LNbitsContext.Provider value={value}>
|
||||
{children}
|
||||
</LNbitsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLNbits () {
|
||||
return useContext(LNbitsContext)
|
||||
}
|
|
@ -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 (
|
||||
<NWCContext.Provider value={value}>
|
||||
{children}
|
||||
</NWCContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
|
@ -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
|
|
@ -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()
|
||||
})
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 } }) {
|
|||
<PriceProvider price={price}>
|
||||
<LightningProvider>
|
||||
<ToastProvider>
|
||||
<ShowModalProvider>
|
||||
<BlockHeightProvider blockHeight={blockHeight}>
|
||||
<ChainFeeProvider chainFee={chainFee}>
|
||||
<ErrorBoundary>
|
||||
<Component ssrData={ssrData} {...otherProps} />
|
||||
<PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />
|
||||
</ErrorBoundary>
|
||||
</ChainFeeProvider>
|
||||
</BlockHeightProvider>
|
||||
</ShowModalProvider>
|
||||
<WebLNProvider>
|
||||
<ShowModalProvider>
|
||||
<BlockHeightProvider blockHeight={blockHeight}>
|
||||
<ChainFeeProvider chainFee={chainFee}>
|
||||
<ErrorBoundary>
|
||||
<Component ssrData={ssrData} {...otherProps} />
|
||||
<PWAPrompt copyBody='This website has app functionality. Add it to your home screen to use it in fullscreen and receive notifications. In Safari:' promptOnVisit={2} />
|
||||
</ErrorBoundary>
|
||||
</ChainFeeProvider>
|
||||
</BlockHeightProvider>
|
||||
</ShowModalProvider>
|
||||
</WebLNProvider>
|
||||
</ToastProvider>
|
||||
</LightningProvider>
|
||||
</PriceProvider>
|
||||
|
|
|
@ -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 () {
|
|||
<h6 className='text-muted text-center'>attach wallets to supplement your SN wallet</h6>
|
||||
<div className={styles.walletGrid}>
|
||||
<LightningAddressWalletCard />
|
||||
<LNbitsCard />
|
||||
<NWCCard />
|
||||
<WalletCard title='coming soon' badges={['probably']} />
|
||||
<WalletCard title='coming soon' badges={['we hope']} />
|
||||
<WalletCard title='coming soon' badges={['tm']} />
|
||||
|
|
|
@ -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 (
|
||||
<CenterLayout>
|
||||
<h2 className='pb-2'>LNbits</h2>
|
||||
<h6 className='text-muted text-center pb-3'>use LNbits for payments</h6>
|
||||
<WalletSecurityBanner />
|
||||
<Form
|
||||
initial={{
|
||||
url: url || '',
|
||||
adminKey: adminKey || '',
|
||||
isDefault: isDefault || false
|
||||
}}
|
||||
schema={lnbitsSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await saveConfig(values)
|
||||
toaster.success('saved settings')
|
||||
router.push('/settings/wallets')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toaster.danger('failed to attach: ' + err.message || err.toString?.())
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ClientInput
|
||||
initialValue={url}
|
||||
label='lnbits url'
|
||||
name='url'
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<ClientInput
|
||||
initialValue={adminKey}
|
||||
type='password'
|
||||
autoComplete='false'
|
||||
label='admin key'
|
||||
name='adminKey'
|
||||
/>
|
||||
<ClientCheckbox
|
||||
disabled={!enabled}
|
||||
initialValue={isDefault}
|
||||
label='default payment method'
|
||||
name='isDefault'
|
||||
/>
|
||||
<WalletButtonBar
|
||||
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?.())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form>
|
||||
</CenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export function LNbitsCard () {
|
||||
const { enabled } = useLNbits()
|
||||
return (
|
||||
<WalletCard
|
||||
title='LNbits'
|
||||
badges={['send only', 'non-custodialish']}
|
||||
provider='lnbits'
|
||||
enabled={enabled}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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 (
|
||||
<CenterLayout>
|
||||
<h2 className='pb-2'>Nostr Wallet Connect</h2>
|
||||
<h6 className='text-muted text-center pb-3'>use Nostr Wallet Connect for payments</h6>
|
||||
<WalletSecurityBanner />
|
||||
<Form
|
||||
initial={{
|
||||
nwcUrl: nwcUrl || '',
|
||||
isDefault: isDefault || false
|
||||
}}
|
||||
schema={nwcSchema}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
await saveConfig(values)
|
||||
toaster.success('saved settings')
|
||||
router.push('/settings/wallets')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toaster.danger('failed to attach: ' + err.message || err.toString?.())
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ClientInput
|
||||
initialValue={nwcUrl}
|
||||
label='connection'
|
||||
name='nwcUrl'
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<ClientCheckbox
|
||||
disabled={!enabled}
|
||||
initialValue={isDefault}
|
||||
label='default payment method'
|
||||
name='isDefault'
|
||||
/>
|
||||
<WalletButtonBar
|
||||
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?.())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form>
|
||||
</CenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export function NWCCard () {
|
||||
const { enabled } = useNWC()
|
||||
return (
|
||||
<WalletCard
|
||||
title='NWC'
|
||||
badges={['send only', 'non-custodialish', 'budgetable']}
|
||||
provider='nwc'
|
||||
enabled={enabled}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue