Compare commits
9 Commits
78520b787b
...
6d3f7d4230
Author | SHA1 | Date | |
---|---|---|---|
|
6d3f7d4230 | ||
|
7c287ae58b | ||
|
396584311f | ||
|
dfd0c27fb2 | ||
|
ba0627fe92 | ||
|
643cb6e2ca | ||
|
506d5c9ce2 | ||
|
4a4d6698bc | ||
|
6df1cdb599 |
@ -8,9 +8,9 @@ import Bolt11Info from './bolt11-info'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { INVOICE } from '@/fragments/wallet'
|
||||
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
|
||||
import { WebLnNotEnabledError } from './payment'
|
||||
import { NoAttachedWalletError } from './payment'
|
||||
|
||||
export default function Invoice ({ invoice, modal, onPayment, info, successVerb, webLn, webLnError, poll }) {
|
||||
export default function Invoice ({ invoice, modal, onPayment, info, successVerb, useWallet, walletError, poll }) {
|
||||
const [expired, setExpired] = useState(new Date(invoice.expiredAt) <= new Date())
|
||||
|
||||
const { data, error } = useQuery(INVOICE, SSR
|
||||
@ -30,8 +30,8 @@ export default function Invoice ({ invoice, modal, onPayment, info, successVerb,
|
||||
return <div>{error.toString()}</div>
|
||||
}
|
||||
|
||||
// if webLn was not passed, use true by default
|
||||
if (webLn === undefined) webLn = true
|
||||
// if useWallet was not passed, use true by default
|
||||
if (useWallet === undefined) useWallet = true
|
||||
|
||||
let variant = 'default'
|
||||
let status = 'waiting for you'
|
||||
@ -39,15 +39,15 @@ export default function Invoice ({ invoice, modal, onPayment, info, successVerb,
|
||||
if (invoice.cancelled) {
|
||||
variant = 'failed'
|
||||
status = 'cancelled'
|
||||
webLn = false
|
||||
useWallet = false
|
||||
} else if (invoice.confirmedAt || (invoice.isHeld && invoice.satsReceived && !expired)) {
|
||||
variant = 'confirmed'
|
||||
status = `${numWithUnits(invoice.satsReceived, { abbreviate: false })} ${successVerb || 'deposited'}`
|
||||
webLn = false
|
||||
useWallet = false
|
||||
} else if (expired) {
|
||||
variant = 'failed'
|
||||
status = 'expired'
|
||||
webLn = false
|
||||
useWallet = false
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@ -60,13 +60,13 @@ export default function Invoice ({ invoice, modal, onPayment, info, successVerb,
|
||||
|
||||
return (
|
||||
<>
|
||||
{webLnError && !(webLnError instanceof WebLnNotEnabledError) &&
|
||||
{walletError && !(walletError instanceof NoAttachedWalletError) &&
|
||||
<div className='text-center text-danger mb-3'>
|
||||
Payment from attached wallet failed:
|
||||
<div>{webLnError.toString()}</div>
|
||||
<div>{walletError.toString()}</div>
|
||||
</div>}
|
||||
<Qr
|
||||
webLn={webLn} value={invoice.bolt11}
|
||||
useWallet={useWallet} value={invoice.bolt11}
|
||||
description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
|
||||
statusVariant={variant} status={status}
|
||||
/>
|
||||
|
@ -1,9 +1,6 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { useMe } from './me'
|
||||
import fancyNames from '@/lib/fancy-names.json'
|
||||
import { gql, useMutation, useQuery } from '@apollo/client'
|
||||
import { WALLET_LOGS } from '@/fragments/wallet'
|
||||
import { getWalletBy } from '@/lib/constants'
|
||||
|
||||
const generateFancyName = () => {
|
||||
// 100 adjectives * 100 nouns * 10000 = 100M possible names
|
||||
@ -44,9 +41,7 @@ export const LoggerContext = createContext()
|
||||
export const LoggerProvider = ({ children }) => {
|
||||
return (
|
||||
<ServiceWorkerLoggerProvider>
|
||||
<WalletLoggerProvider>
|
||||
{children}
|
||||
</WalletLoggerProvider>
|
||||
{children}
|
||||
</ServiceWorkerLoggerProvider>
|
||||
)
|
||||
}
|
||||
@ -122,189 +117,3 @@ function ServiceWorkerLoggerProvider ({ children }) {
|
||||
export function useServiceWorkerLogger () {
|
||||
return useContext(ServiceWorkerLoggerContext)
|
||||
}
|
||||
|
||||
const WalletLoggerContext = createContext()
|
||||
const WalletLogsContext = createContext()
|
||||
|
||||
const initIndexedDB = async (dbName, storeName) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!window.indexedDB) {
|
||||
return reject(new Error('IndexedDB not supported'))
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB
|
||||
const request = window.indexedDB.open(dbName, 1)
|
||||
|
||||
let db
|
||||
request.onupgradeneeded = () => {
|
||||
// this only runs if version was changed during open
|
||||
db = request.result
|
||||
if (!db.objectStoreNames.contains(storeName)) {
|
||||
const objectStore = db.createObjectStore(storeName, { autoIncrement: true })
|
||||
objectStore.createIndex('ts', 'ts')
|
||||
objectStore.createIndex('wallet_ts', ['wallet', 'ts'])
|
||||
}
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
// this gets called after onupgradeneeded finished
|
||||
db = request.result
|
||||
resolve(db)
|
||||
}
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('failed to open IndexedDB'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const WalletLoggerProvider = ({ children }) => {
|
||||
const me = useMe()
|
||||
const [logs, setLogs] = useState([])
|
||||
let dbName = 'app:storage'
|
||||
if (me) {
|
||||
dbName = `${dbName}:${me.id}`
|
||||
}
|
||||
const idbStoreName = 'wallet_logs'
|
||||
const idb = useRef()
|
||||
const logQueue = useRef([])
|
||||
|
||||
useQuery(WALLET_LOGS, {
|
||||
fetchPolicy: 'network-only',
|
||||
// required to trigger onCompleted on refetches
|
||||
notifyOnNetworkStatusChange: true,
|
||||
onCompleted: ({ walletLogs }) => {
|
||||
setLogs((prevLogs) => {
|
||||
const existingIds = prevLogs.map(({ id }) => id)
|
||||
const logs = walletLogs
|
||||
.filter(({ id }) => !existingIds.includes(id))
|
||||
.map(({ createdAt, wallet: walletType, ...log }) => {
|
||||
return {
|
||||
ts: +new Date(createdAt),
|
||||
wallet: getWalletBy('type', walletType).logTag,
|
||||
...log
|
||||
}
|
||||
})
|
||||
return [...prevLogs, ...logs].sort((a, b) => a.ts - b.ts)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const [deleteServerWalletLogs] = useMutation(
|
||||
gql`
|
||||
mutation deleteWalletLogs($wallet: String) {
|
||||
deleteWalletLogs(wallet: $wallet)
|
||||
}
|
||||
`,
|
||||
{
|
||||
onCompleted: (_, { variables: { wallet: walletType } }) => {
|
||||
setLogs((logs) => {
|
||||
return logs.filter(l => walletType ? l.wallet !== getWalletBy('type', walletType).logTag : false)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const saveLog = useCallback((log) => {
|
||||
if (!idb.current) {
|
||||
// IDB may not be ready yet
|
||||
return logQueue.current.push(log)
|
||||
}
|
||||
const tx = idb.current.transaction(idbStoreName, 'readwrite')
|
||||
const request = tx.objectStore(idbStoreName).add(log)
|
||||
request.onerror = () => console.error('failed to save log:', log)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
initIndexedDB(dbName, idbStoreName)
|
||||
.then(db => {
|
||||
idb.current = db
|
||||
|
||||
// load all logs from IDB
|
||||
const tx = idb.current.transaction(idbStoreName, 'readonly')
|
||||
const store = tx.objectStore(idbStoreName)
|
||||
const index = store.index('ts')
|
||||
const request = index.getAll()
|
||||
request.onsuccess = () => {
|
||||
const logs = request.result
|
||||
setLogs((prevLogs) => {
|
||||
// sort oldest first to keep same order as logs are appended
|
||||
return [...prevLogs, ...logs].sort((a, b) => a.ts - b.ts)
|
||||
})
|
||||
}
|
||||
|
||||
// flush queued logs to IDB
|
||||
logQueue.current.forEach(q => {
|
||||
const isLog = !!q.wallet
|
||||
if (isLog) saveLog(q)
|
||||
})
|
||||
|
||||
logQueue.current = []
|
||||
})
|
||||
.catch(console.error)
|
||||
return () => idb.current?.close()
|
||||
}, [])
|
||||
|
||||
const appendLog = useCallback((wallet, level, message) => {
|
||||
const log = { wallet: wallet.logTag, level, message, ts: +new Date() }
|
||||
saveLog(log)
|
||||
setLogs((prevLogs) => [...prevLogs, log])
|
||||
}, [saveLog])
|
||||
|
||||
const deleteLogs = useCallback(async (wallet) => {
|
||||
if (!wallet || wallet.server) {
|
||||
await deleteServerWalletLogs({ variables: { wallet: wallet?.type } })
|
||||
}
|
||||
if (!wallet || !wallet.server) {
|
||||
const tx = idb.current.transaction(idbStoreName, 'readwrite')
|
||||
const objectStore = tx.objectStore(idbStoreName)
|
||||
const idx = objectStore.index('wallet_ts')
|
||||
const request = wallet ? idx.openCursor(window.IDBKeyRange.bound([wallet.logTag, -Infinity], [wallet.logTag, Infinity])) : idx.openCursor()
|
||||
request.onsuccess = function (event) {
|
||||
const cursor = event.target.result
|
||||
if (cursor) {
|
||||
cursor.delete()
|
||||
cursor.continue()
|
||||
} else {
|
||||
// finished
|
||||
setLogs((logs) => logs.filter(l => wallet ? l.wallet !== wallet.logTag : false))
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [setLogs])
|
||||
|
||||
return (
|
||||
<WalletLogsContext.Provider value={logs}>
|
||||
<WalletLoggerContext.Provider value={{ appendLog, deleteLogs }}>
|
||||
{children}
|
||||
</WalletLoggerContext.Provider>
|
||||
</WalletLogsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useWalletLogger (wallet) {
|
||||
const { appendLog, deleteLogs: innerDeleteLogs } = useContext(WalletLoggerContext)
|
||||
|
||||
const log = useCallback(level => message => {
|
||||
// TODO:
|
||||
// also send this to us if diagnostics was enabled,
|
||||
// very similar to how the service worker logger works.
|
||||
appendLog(wallet, level, message)
|
||||
console[level !== 'error' ? 'info' : 'error'](`[${wallet.logTag}]`, message)
|
||||
}, [appendLog, wallet])
|
||||
|
||||
const logger = useMemo(() => ({
|
||||
ok: (...message) => log('ok')(message.join(' ')),
|
||||
info: (...message) => log('info')(message.join(' ')),
|
||||
error: (...message) => log('error')(message.join(' '))
|
||||
}), [log, wallet])
|
||||
|
||||
const deleteLogs = useCallback((w) => innerDeleteLogs(w || wallet), [innerDeleteLogs, wallet])
|
||||
|
||||
return { logger, deleteLogs }
|
||||
}
|
||||
|
||||
export function useWalletLogs (wallet) {
|
||||
const logs = useContext(WalletLogsContext)
|
||||
return logs.filter(l => !wallet || l.wallet === wallet.logTag)
|
||||
}
|
||||
|
@ -22,8 +22,7 @@ import SearchIcon from '../../svgs/search-line.svg'
|
||||
import classNames from 'classnames'
|
||||
import SnIcon from '@/svgs/sn.svg'
|
||||
import { useHasNewNotes } from '../use-has-new-notes'
|
||||
import { useWalletLogger } from '../logger'
|
||||
import { useWebLNConfigurator } from '../webln'
|
||||
import { useWalletLogger } from '@/components/wallet-logger'
|
||||
|
||||
export function Brand ({ className }) {
|
||||
return (
|
||||
@ -257,7 +256,7 @@ export default function LoginButton ({ className }) {
|
||||
|
||||
export function LogoutDropdownItem () {
|
||||
const { registration: swRegistration, togglePushSubscription } = useServiceWorker()
|
||||
const webLN = useWebLNConfigurator()
|
||||
// const wallet = useWallet()
|
||||
const { deleteLogs } = useWalletLogger()
|
||||
return (
|
||||
<Dropdown.Item
|
||||
@ -267,8 +266,8 @@ export function LogoutDropdownItem () {
|
||||
if (pushSubscription) {
|
||||
await togglePushSubscription().catch(console.error)
|
||||
}
|
||||
// detach wallets
|
||||
await webLN.clearConfig().catch(console.error)
|
||||
// TODO: detach wallets
|
||||
// await wallet.detachAll().catch(console.error)
|
||||
// delete client wallet logs to prevent leak of private data if a shared device was used
|
||||
await deleteLogs(Wallet.NWC).catch(console.error)
|
||||
await deleteLogs(Wallet.LNbits).catch(console.error)
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useMe } from './me'
|
||||
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
||||
import { useWebLN } from './webln'
|
||||
import { useWallet } from './wallet'
|
||||
import { FAST_POLL_INTERVAL, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
|
||||
import { INVOICE } from '@/fragments/wallet'
|
||||
import Invoice from '@/components/invoice'
|
||||
@ -15,10 +15,10 @@ export class InvoiceCanceledError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class WebLnNotEnabledError extends Error {
|
||||
export class NoAttachedWalletError extends Error {
|
||||
constructor () {
|
||||
super('no enabled WebLN provider found')
|
||||
this.name = 'WebLnNotEnabledError'
|
||||
super('no attached wallet found')
|
||||
this.name = 'NoAttachedWalletError'
|
||||
}
|
||||
}
|
||||
|
||||
@ -102,19 +102,19 @@ const useInvoice = () => {
|
||||
return { create, isPaid, waitUntilPaid, cancel }
|
||||
}
|
||||
|
||||
const useWebLnPayment = () => {
|
||||
const useWalletPayment = () => {
|
||||
const invoice = useInvoice()
|
||||
const provider = useWebLN()
|
||||
const wallet = useWallet()
|
||||
|
||||
const waitForWebLnPayment = useCallback(async ({ id, bolt11 }) => {
|
||||
if (!provider) {
|
||||
throw new WebLnNotEnabledError()
|
||||
const waitForWalletPayment = useCallback(async ({ id, bolt11 }) => {
|
||||
if (!wallet) {
|
||||
throw new NoAttachedWalletError()
|
||||
}
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
// can't use await here since we might pay JIT invoices and sendPaymentAsync is not supported yet.
|
||||
// see https://www.webln.guide/building-lightning-apps/webln-reference/webln.sendpaymentasync
|
||||
provider.sendPayment(bolt11)
|
||||
wallet.sendPayment(bolt11)
|
||||
// JIT invoice payments will never resolve here
|
||||
// since they only get resolved after settlement which can't happen here
|
||||
.then(resolve)
|
||||
@ -124,19 +124,19 @@ const useWebLnPayment = () => {
|
||||
.catch(reject)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('WebLN payment failed:', err)
|
||||
console.error('payment failed:', err)
|
||||
throw err
|
||||
}
|
||||
}, [provider, invoice])
|
||||
}, [wallet, invoice])
|
||||
|
||||
return waitForWebLnPayment
|
||||
return waitForWalletPayment
|
||||
}
|
||||
|
||||
const useQrPayment = () => {
|
||||
const invoice = useInvoice()
|
||||
const showModal = useShowModal()
|
||||
|
||||
const waitForQrPayment = useCallback(async (inv, webLnError) => {
|
||||
const waitForQrPayment = useCallback(async (inv, walletError) => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
let paid
|
||||
const cancelAndReject = async (onClose) => {
|
||||
@ -149,8 +149,8 @@ const useQrPayment = () => {
|
||||
invoice={inv}
|
||||
modal
|
||||
successVerb='received'
|
||||
webLn={false}
|
||||
webLnError={webLnError}
|
||||
useWallet={false}
|
||||
walletError={walletError}
|
||||
onPayment={() => { paid = true; onClose(); resolve() }}
|
||||
poll
|
||||
/>,
|
||||
@ -165,22 +165,22 @@ export const usePayment = () => {
|
||||
const me = useMe()
|
||||
const feeButton = useFeeButton()
|
||||
const invoice = useInvoice()
|
||||
const waitForWebLnPayment = useWebLnPayment()
|
||||
const waitForWalletPayment = useWalletPayment()
|
||||
const waitForQrPayment = useQrPayment()
|
||||
|
||||
const waitForPayment = useCallback(async (invoice) => {
|
||||
let webLnError
|
||||
let walletError
|
||||
try {
|
||||
return await waitForWebLnPayment(invoice)
|
||||
return await waitForWalletPayment(invoice)
|
||||
} catch (err) {
|
||||
if (err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError) {
|
||||
// bail since qr code payment will also fail
|
||||
throw err
|
||||
}
|
||||
webLnError = err
|
||||
walletError = err
|
||||
}
|
||||
return await waitForQrPayment(invoice, webLnError)
|
||||
}, [waitForWebLnPayment, waitForQrPayment])
|
||||
return await waitForQrPayment(invoice, walletError)
|
||||
}, [waitForWalletPayment, waitForQrPayment])
|
||||
|
||||
const request = useCallback(async (amount) => {
|
||||
amount ??= feeButton?.total
|
||||
|
@ -2,26 +2,26 @@ import QRCode from 'qrcode.react'
|
||||
import { CopyInput, InputSkeleton } from './form'
|
||||
import InvoiceStatus from './invoice-status'
|
||||
import { useEffect } from 'react'
|
||||
import { useWebLN } from './webln'
|
||||
import { useWallet } from './wallet'
|
||||
import SimpleCountdown from './countdown'
|
||||
import Bolt11Info from './bolt11-info'
|
||||
|
||||
export default function Qr ({ asIs, value, webLn, statusVariant, description, status }) {
|
||||
export default function Qr ({ asIs, value, useWallet: automated, statusVariant, description, status }) {
|
||||
const qrValue = asIs ? value : 'lightning:' + value.toUpperCase()
|
||||
const provider = useWebLN()
|
||||
const wallet = useWallet()
|
||||
|
||||
useEffect(() => {
|
||||
async function effect () {
|
||||
if (webLn && provider) {
|
||||
if (automated && wallet) {
|
||||
try {
|
||||
await provider.sendPayment(value)
|
||||
await wallet.sendPayment(value)
|
||||
} catch (e) {
|
||||
console.log(e?.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
effect()
|
||||
}, [provider])
|
||||
}, [wallet])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
22
components/use-local-state.js
Normal file
22
components/use-local-state.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
export default function useLocalState (storageKey, initialValue = '') {
|
||||
const [value, innerSetValue] = useState(initialValue)
|
||||
|
||||
useEffect(() => {
|
||||
const value = window.localStorage.getItem(storageKey)
|
||||
innerSetValue(JSON.parse(value))
|
||||
}, [storageKey])
|
||||
|
||||
const setValue = useCallback((newValue) => {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(newValue))
|
||||
innerSetValue(newValue)
|
||||
}, [storageKey])
|
||||
|
||||
const clearValue = useCallback(() => {
|
||||
window.localStorage.removeItem(storageKey)
|
||||
innerSetValue(null)
|
||||
}, [storageKey])
|
||||
|
||||
return [value, setValue, clearValue]
|
||||
}
|
@ -5,14 +5,13 @@ import Gear from '@/svgs/settings-5-fill.svg'
|
||||
import Link from 'next/link'
|
||||
import CancelButton from './cancel-button'
|
||||
import { SubmitButton } from './form'
|
||||
import { Status } from './webln'
|
||||
import { useWallet, Status } from './wallet'
|
||||
|
||||
export const isConfigured = status => [Status.Enabled, Status.Locked, Status.Error, true].includes(status)
|
||||
export function WalletCard ({ name, title, badges, status }) {
|
||||
const wallet = useWallet(name)
|
||||
|
||||
export function WalletCard ({ title, badges, provider, status }) {
|
||||
const configured = isConfigured(status)
|
||||
let indicator = styles.disabled
|
||||
switch (status) {
|
||||
switch (wallet.status) {
|
||||
case Status.Enabled:
|
||||
case true:
|
||||
indicator = styles.success
|
||||
@ -42,33 +41,31 @@ export function WalletCard ({ title, badges, provider, status }) {
|
||||
</Badge>)}
|
||||
</Card.Subtitle>
|
||||
</Card.Body>
|
||||
{provider &&
|
||||
<Link href={`/settings/wallets/${provider}`}>
|
||||
<Card.Footer className={styles.attach}>
|
||||
{configured
|
||||
? <>configure<Gear width={14} height={14} /></>
|
||||
: <>attach<Plug width={14} height={14} /></>}
|
||||
</Card.Footer>
|
||||
</Link>}
|
||||
<Link href={`/settings/wallets/${name}`}>
|
||||
<Card.Footer className={styles.attach}>
|
||||
{wallet.isConfigured
|
||||
? <>configure<Gear width={14} height={14} /></>
|
||||
: <>attach<Plug width={14} height={14} /></>}
|
||||
</Card.Footer>
|
||||
</Link>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function WalletButtonBar ({
|
||||
status, disable,
|
||||
wallet, disable,
|
||||
className, children, onDelete, onCancel, hasCancel = true,
|
||||
createText = 'attach', deleteText = 'detach', editText = 'save'
|
||||
}) {
|
||||
const configured = isConfigured(status)
|
||||
return (
|
||||
<div className={`mt-3 ${className}`}>
|
||||
<div className='d-flex justify-content-between'>
|
||||
{configured &&
|
||||
{wallet.isConfigured &&
|
||||
<Button onClick={onDelete} variant='grey-medium'>{deleteText}</Button>}
|
||||
{children}
|
||||
<div className='d-flex align-items-center ms-auto'>
|
||||
{hasCancel && <CancelButton onClick={onCancel} />}
|
||||
<SubmitButton variant='primary' disabled={disable}>{configured ? editText : createText}</SubmitButton>
|
||||
<SubmitButton variant='primary' disabled={disable}>{wallet.isConfigured ? editText : createText}</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
321
components/wallet-logger.js
Normal file
321
components/wallet-logger.js
Normal file
@ -0,0 +1,321 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import LogMessage from './log-message'
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Checkbox, Form } from './form'
|
||||
import { useField } from 'formik'
|
||||
import styles from '@/styles/log.module.css'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { useToast } from './toast'
|
||||
import { useShowModal } from './modal'
|
||||
import { WALLET_LOGS } from '@/fragments/wallet'
|
||||
import { getWalletByName } from './wallet'
|
||||
import { gql, useMutation, useQuery } from '@apollo/client'
|
||||
import { useMe } from './me'
|
||||
|
||||
const FollowCheckbox = ({ value, ...props }) => {
|
||||
const [,, helpers] = useField(props.name)
|
||||
|
||||
useEffect(() => {
|
||||
helpers.setValue(value)
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<Checkbox {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export function WalletLogs ({ wallet, embedded }) {
|
||||
const logs = useWalletLogs(wallet)
|
||||
|
||||
const router = useRouter()
|
||||
const { follow: defaultFollow } = router.query
|
||||
const [follow, setFollow] = useState(defaultFollow ?? true)
|
||||
const tableRef = useRef()
|
||||
const scrollY = useRef()
|
||||
const showModal = useShowModal()
|
||||
|
||||
useEffect(() => {
|
||||
if (follow) {
|
||||
tableRef.current?.scroll({ top: tableRef.current.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
}, [logs, follow])
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll (e) {
|
||||
const y = e.target.scrollTop
|
||||
|
||||
const down = y - scrollY.current >= -1
|
||||
if (!!scrollY.current && !down) {
|
||||
setFollow(false)
|
||||
}
|
||||
|
||||
const maxY = e.target.scrollHeight - e.target.clientHeight
|
||||
const dY = maxY - y
|
||||
const isBottom = dY >= -1 && dY <= 1
|
||||
if (isBottom) {
|
||||
setFollow(true)
|
||||
}
|
||||
|
||||
scrollY.current = y
|
||||
}
|
||||
tableRef.current?.addEventListener('scroll', onScroll)
|
||||
return () => tableRef.current?.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='d-flex w-100 align-items-center mb-3'>
|
||||
<Form initial={{ follow: true }}>
|
||||
<FollowCheckbox
|
||||
label='follow logs' name='follow' value={follow}
|
||||
handleChange={setFollow} groupClassName='mb-0'
|
||||
/>
|
||||
</Form>
|
||||
<span
|
||||
style={{ cursor: 'pointer' }}
|
||||
className='text-muted fw-bold nav-link' onClick={() => {
|
||||
showModal(onClose => <DeleteWalletLogsObstacle wallet={wallet} onClose={onClose} />)
|
||||
}}
|
||||
>clear
|
||||
</span>
|
||||
</div>
|
||||
<div ref={tableRef} className={`${styles.logTable} ${embedded ? styles.embedded : ''}`}>
|
||||
<div className='w-100 text-center'>------ start of logs ------</div>
|
||||
{logs.length === 0 && <div className='w-100 text-center'>empty</div>}
|
||||
<table>
|
||||
<tbody>
|
||||
{logs.map((log, i) => <LogMessage key={i} {...log} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteWalletLogsObstacle ({ wallet, onClose }) {
|
||||
const toaster = useToast()
|
||||
const { deleteLogs } = useWalletLogger(wallet)
|
||||
|
||||
const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?`
|
||||
return (
|
||||
<div className='text-center'>
|
||||
{prompt}
|
||||
<div className='d-flex justify-center align-items-center mt-3 mx-auto'>
|
||||
<span style={{ cursor: 'pointer' }} className='d-flex ms-auto text-muted fw-bold nav-link mx-3' onClick={onClose}>cancel</span>
|
||||
<Button
|
||||
className='d-flex me-auto mx-3' variant='danger'
|
||||
onClick={
|
||||
async () => {
|
||||
try {
|
||||
await deleteLogs()
|
||||
onClose()
|
||||
toaster.success('deleted wallet logs')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toaster.danger('failed to delete wallet logs')
|
||||
}
|
||||
}
|
||||
}
|
||||
>delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const WalletLoggerContext = createContext()
|
||||
const WalletLogsContext = createContext()
|
||||
|
||||
const initIndexedDB = async (dbName, storeName) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!window.indexedDB) {
|
||||
return reject(new Error('IndexedDB not supported'))
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB
|
||||
const request = window.indexedDB.open(dbName, 1)
|
||||
|
||||
let db
|
||||
request.onupgradeneeded = () => {
|
||||
// this only runs if version was changed during open
|
||||
db = request.result
|
||||
if (!db.objectStoreNames.contains(storeName)) {
|
||||
const objectStore = db.createObjectStore(storeName, { autoIncrement: true })
|
||||
objectStore.createIndex('ts', 'ts')
|
||||
objectStore.createIndex('wallet_ts', ['wallet', 'ts'])
|
||||
}
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
// this gets called after onupgradeneeded finished
|
||||
db = request.result
|
||||
resolve(db)
|
||||
}
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('failed to open IndexedDB'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const WalletLoggerProvider = ({ children }) => {
|
||||
const me = useMe()
|
||||
const [logs, setLogs] = useState([])
|
||||
let dbName = 'app:storage'
|
||||
if (me) {
|
||||
dbName = `${dbName}:${me.id}`
|
||||
}
|
||||
const idbStoreName = 'wallet_logs'
|
||||
const idb = useRef()
|
||||
const logQueue = useRef([])
|
||||
|
||||
useQuery(WALLET_LOGS, {
|
||||
fetchPolicy: 'network-only',
|
||||
// required to trigger onCompleted on refetches
|
||||
notifyOnNetworkStatusChange: true,
|
||||
onCompleted: ({ walletLogs }) => {
|
||||
setLogs((prevLogs) => {
|
||||
const existingIds = prevLogs.map(({ id }) => id)
|
||||
const logs = walletLogs
|
||||
.filter(({ id }) => !existingIds.includes(id))
|
||||
.map(({ createdAt, wallet: walletType, ...log }) => {
|
||||
return {
|
||||
ts: +new Date(createdAt),
|
||||
// TODO: use wallet defs
|
||||
// wallet: getWalletBy('type', walletType).logTag,
|
||||
...log
|
||||
}
|
||||
})
|
||||
return [...prevLogs, ...logs].sort((a, b) => a.ts - b.ts)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const [deleteServerWalletLogs] = useMutation(
|
||||
gql`
|
||||
mutation deleteWalletLogs($wallet: String) {
|
||||
deleteWalletLogs(wallet: $wallet)
|
||||
}
|
||||
`,
|
||||
{
|
||||
onCompleted: (_, { variables: { wallet: walletType } }) => {
|
||||
setLogs((logs) => {
|
||||
// TODO: use wallet defs
|
||||
return logs.filter(l => walletType ? l.wallet !== getWalletByName('type', walletType) : false)
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const saveLog = useCallback((log) => {
|
||||
if (!idb.current) {
|
||||
// IDB may not be ready yet
|
||||
return logQueue.current.push(log)
|
||||
}
|
||||
const tx = idb.current.transaction(idbStoreName, 'readwrite')
|
||||
const request = tx.objectStore(idbStoreName).add(log)
|
||||
request.onerror = () => console.error('failed to save log:', log)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
initIndexedDB(dbName, idbStoreName)
|
||||
.then(db => {
|
||||
idb.current = db
|
||||
|
||||
// load all logs from IDB
|
||||
const tx = idb.current.transaction(idbStoreName, 'readonly')
|
||||
const store = tx.objectStore(idbStoreName)
|
||||
const index = store.index('ts')
|
||||
const request = index.getAll()
|
||||
request.onsuccess = () => {
|
||||
let logs = request.result
|
||||
setLogs((prevLogs) => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// in dev mode, useEffect runs twice, so we filter out duplicates here
|
||||
const existingIds = prevLogs.map(({ id }) => id)
|
||||
logs = logs.filter(({ id }) => !existingIds.includes(id))
|
||||
}
|
||||
// sort oldest first to keep same order as logs are appended
|
||||
return [...prevLogs, ...logs].sort((a, b) => a.ts - b.ts)
|
||||
})
|
||||
}
|
||||
|
||||
// flush queued logs to IDB
|
||||
logQueue.current.forEach(q => {
|
||||
const isLog = !!q.wallet
|
||||
if (isLog) saveLog(q)
|
||||
})
|
||||
|
||||
logQueue.current = []
|
||||
})
|
||||
.catch(console.error)
|
||||
return () => idb.current?.close()
|
||||
}, [])
|
||||
|
||||
const appendLog = useCallback((wallet, level, message) => {
|
||||
const log = { wallet: wallet.name, level, message, ts: +new Date() }
|
||||
saveLog(log)
|
||||
setLogs((prevLogs) => [...prevLogs, log])
|
||||
}, [saveLog])
|
||||
|
||||
const deleteLogs = useCallback(async (wallet) => {
|
||||
if (!wallet || wallet.canReceive) {
|
||||
await deleteServerWalletLogs({ variables: { wallet: wallet?.type } })
|
||||
}
|
||||
if (!wallet || wallet.canPay) {
|
||||
const tx = idb.current.transaction(idbStoreName, 'readwrite')
|
||||
const objectStore = tx.objectStore(idbStoreName)
|
||||
const idx = objectStore.index('wallet_ts')
|
||||
const request = wallet ? idx.openCursor(window.IDBKeyRange.bound([wallet.name, -Infinity], [wallet.name, Infinity])) : idx.openCursor()
|
||||
request.onsuccess = function (event) {
|
||||
const cursor = event.target.result
|
||||
if (cursor) {
|
||||
cursor.delete()
|
||||
cursor.continue()
|
||||
} else {
|
||||
// finished
|
||||
setLogs((logs) => logs.filter(l => wallet ? l.wallet !== wallet.name : false))
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [me, setLogs])
|
||||
|
||||
return (
|
||||
<WalletLogsContext.Provider value={logs}>
|
||||
<WalletLoggerContext.Provider value={{ appendLog, deleteLogs }}>
|
||||
{children}
|
||||
</WalletLoggerContext.Provider>
|
||||
</WalletLogsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useWalletLogger (wallet) {
|
||||
const { appendLog, deleteLogs: innerDeleteLogs } = useContext(WalletLoggerContext)
|
||||
|
||||
const log = useCallback(level => message => {
|
||||
if (!wallet) {
|
||||
console.error('cannot log: no wallet set')
|
||||
return
|
||||
}
|
||||
// TODO:
|
||||
// also send this to us if diagnostics was enabled,
|
||||
// very similar to how the service worker logger works.
|
||||
appendLog(wallet, level, message)
|
||||
console[level !== 'error' ? 'info' : 'error'](`[${wallet.name}]`, message)
|
||||
}, [appendLog, wallet])
|
||||
|
||||
const logger = useMemo(() => ({
|
||||
ok: (...message) => log('ok')(message.join(' ')),
|
||||
info: (...message) => log('info')(message.join(' ')),
|
||||
error: (...message) => log('error')(message.join(' '))
|
||||
}), [log, wallet?.name])
|
||||
|
||||
const deleteLogs = useCallback((w) => innerDeleteLogs(w || wallet), [innerDeleteLogs, wallet])
|
||||
|
||||
return { logger, deleteLogs }
|
||||
}
|
||||
|
||||
export function useWalletLogs (wallet) {
|
||||
const logs = useContext(WalletLogsContext)
|
||||
return logs.filter(l => !wallet || l.wallet === wallet.name)
|
||||
}
|
@ -1,121 +0,0 @@
|
||||
import { useRouter } from 'next/router'
|
||||
import LogMessage from './log-message'
|
||||
import { useWalletLogger, useWalletLogs } from './logger'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Checkbox, Form } from './form'
|
||||
import { useField } from 'formik'
|
||||
import styles from '@/styles/log.module.css'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { useToast } from './toast'
|
||||
import { useShowModal } from './modal'
|
||||
|
||||
const FollowCheckbox = ({ value, ...props }) => {
|
||||
const [,, helpers] = useField(props.name)
|
||||
|
||||
useEffect(() => {
|
||||
helpers.setValue(value)
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<Checkbox {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export default function WalletLogs ({ wallet, embedded }) {
|
||||
const logs = useWalletLogs(wallet)
|
||||
|
||||
const router = useRouter()
|
||||
const { follow: defaultFollow } = router.query
|
||||
const [follow, setFollow] = useState(defaultFollow ?? true)
|
||||
const tableRef = useRef()
|
||||
const scrollY = useRef()
|
||||
const showModal = useShowModal()
|
||||
|
||||
useEffect(() => {
|
||||
if (follow) {
|
||||
tableRef.current?.scroll({ top: tableRef.current.scrollHeight, behavior: 'smooth' })
|
||||
}
|
||||
}, [logs, follow])
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll (e) {
|
||||
const y = e.target.scrollTop
|
||||
|
||||
const down = y - scrollY.current >= -1
|
||||
if (!!scrollY.current && !down) {
|
||||
setFollow(false)
|
||||
}
|
||||
|
||||
const maxY = e.target.scrollHeight - e.target.clientHeight
|
||||
const dY = maxY - y
|
||||
const isBottom = dY >= -1 && dY <= 1
|
||||
if (isBottom) {
|
||||
setFollow(true)
|
||||
}
|
||||
|
||||
scrollY.current = y
|
||||
}
|
||||
tableRef.current?.addEventListener('scroll', onScroll)
|
||||
return () => tableRef.current?.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='d-flex w-100 align-items-center mb-3'>
|
||||
<Form initial={{ follow: true }}>
|
||||
<FollowCheckbox
|
||||
label='follow logs' name='follow' value={follow}
|
||||
handleChange={setFollow} groupClassName='mb-0'
|
||||
/>
|
||||
</Form>
|
||||
<span
|
||||
style={{ cursor: 'pointer' }}
|
||||
className='text-muted fw-bold nav-link' onClick={() => {
|
||||
showModal(onClose => <DeleteWalletLogsObstacle wallet={wallet} onClose={onClose} />)
|
||||
}}
|
||||
>clear
|
||||
</span>
|
||||
</div>
|
||||
<div ref={tableRef} className={`${styles.logTable} ${embedded ? styles.embedded : ''}`}>
|
||||
<div className='w-100 text-center'>------ start of logs ------</div>
|
||||
{logs.length === 0 && <div className='w-100 text-center'>empty</div>}
|
||||
<table>
|
||||
<tbody>
|
||||
{logs.map((log, i) => <LogMessage key={i} {...log} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteWalletLogsObstacle ({ wallet, onClose }) {
|
||||
const toaster = useToast()
|
||||
const { deleteLogs } = useWalletLogger(wallet)
|
||||
|
||||
const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?`
|
||||
return (
|
||||
<div className='text-center'>
|
||||
{prompt}
|
||||
<div className='d-flex justify-center align-items-center mt-3 mx-auto'>
|
||||
<span style={{ cursor: 'pointer' }} className='d-flex ms-auto text-muted fw-bold nav-link mx-3' onClick={onClose}>cancel</span>
|
||||
<Button
|
||||
className='d-flex me-auto mx-3' variant='danger'
|
||||
onClick={
|
||||
async () => {
|
||||
try {
|
||||
await deleteLogs()
|
||||
onClose()
|
||||
toaster.success('deleted wallet logs')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toaster.danger('failed to delete wallet logs')
|
||||
}
|
||||
}
|
||||
}
|
||||
>delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
146
components/wallet/index.js
Normal file
146
components/wallet/index.js
Normal file
@ -0,0 +1,146 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useMe } from '@/components/me'
|
||||
import useLocalState from '@/components/use-local-state'
|
||||
import { useWalletLogger } from '@/components/wallet-logger'
|
||||
import { SSR } from '@/lib/constants'
|
||||
import { bolt11Tags } from '@/lib/bolt11'
|
||||
|
||||
// wallet definitions
|
||||
export const WALLET_DEFS = [
|
||||
await import('@/components/wallet/lnbits'),
|
||||
await import('@/components/wallet/nwc')
|
||||
]
|
||||
|
||||
export const Status = {
|
||||
Initialized: 'Initialized',
|
||||
Enabled: 'Enabled',
|
||||
Locked: 'Locked',
|
||||
Error: 'Error'
|
||||
}
|
||||
|
||||
export function useWallet (name) {
|
||||
const me = useMe()
|
||||
|
||||
const wallet = name ? getWalletByName(name, me) : getEnabledWallet(me)
|
||||
const { logger } = useWalletLogger(wallet)
|
||||
const storageKey = getStorageKey(wallet?.name, me)
|
||||
const [config, saveConfig, clearConfig] = useLocalState(storageKey)
|
||||
|
||||
const sendPayment = useCallback(async (bolt11) => {
|
||||
const hash = bolt11Tags(bolt11).payment_hash
|
||||
logger.info('sending payment:', `payment_hash=${hash}`)
|
||||
try {
|
||||
const { preimage } = await wallet.sendPayment({ bolt11, config })
|
||||
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
|
||||
} catch (err) {
|
||||
const message = err.message || err.toString?.()
|
||||
logger.error('payment failed:', `payment_hash=${hash}`, message)
|
||||
throw err
|
||||
}
|
||||
}, [wallet, config, logger])
|
||||
|
||||
const validate = useCallback(async (values) => {
|
||||
try {
|
||||
// validate should log custom INFO and OK message
|
||||
// TODO: add timeout
|
||||
return await wallet.validate({ logger, ...values })
|
||||
} catch (err) {
|
||||
const message = err.message || err.toString?.()
|
||||
logger.error(message)
|
||||
throw err
|
||||
}
|
||||
}, [wallet, logger])
|
||||
|
||||
const enable = useCallback(() => {
|
||||
enableWallet(name, me)
|
||||
logger.ok('wallet enabled')
|
||||
}, [name, me, logger])
|
||||
|
||||
const disable = useCallback(() => {
|
||||
disableWallet(name, me)
|
||||
logger.ok('wallet disabled')
|
||||
}, [name, me, logger])
|
||||
|
||||
const save = useCallback((values) => {
|
||||
try {
|
||||
saveConfig(values)
|
||||
logger.ok('wallet attached')
|
||||
} catch (err) {
|
||||
const message = 'failed to attach: ' + err.message || err.toString?.()
|
||||
logger.error(message)
|
||||
throw err
|
||||
}
|
||||
}, [saveConfig, logger])
|
||||
|
||||
// delete is a reserved keyword
|
||||
const delete_ = useCallback(() => {
|
||||
try {
|
||||
clearConfig()
|
||||
logger.ok('wallet detached')
|
||||
} catch (err) {
|
||||
const message = 'failed to detach: ' + err.message || err.toString?.()
|
||||
logger.error(message)
|
||||
throw err
|
||||
}
|
||||
}, [clearConfig, logger])
|
||||
|
||||
return {
|
||||
...wallet,
|
||||
sendPayment,
|
||||
validate,
|
||||
config,
|
||||
save,
|
||||
delete: delete_,
|
||||
enable,
|
||||
disable,
|
||||
isConfigured: !!config,
|
||||
status: config?.enabled ? Status.Enabled : Status.Initialized,
|
||||
canPay: !!wallet?.sendPayment,
|
||||
canReceive: !!wallet?.createInvoice,
|
||||
logger
|
||||
}
|
||||
}
|
||||
|
||||
export function getWalletByName (name, me) {
|
||||
return WALLET_DEFS.find(def => def.name === name)
|
||||
}
|
||||
|
||||
export function getEnabledWallet (me) {
|
||||
// TODO: handle multiple enabled wallets
|
||||
return WALLET_DEFS
|
||||
.filter(def => !!def.sendPayment)
|
||||
.find(def => {
|
||||
const key = getStorageKey(def.name, me)
|
||||
const config = SSR ? null : JSON.parse(window?.localStorage.getItem(key))
|
||||
return config?.enabled
|
||||
})
|
||||
}
|
||||
|
||||
function getStorageKey (name, me) {
|
||||
let storageKey = `wallet:${name}`
|
||||
if (me) {
|
||||
storageKey = `${storageKey}:${me.id}`
|
||||
}
|
||||
return storageKey
|
||||
}
|
||||
|
||||
function enableWallet (name, me) {
|
||||
// mark all wallets as disabled except the one to enable
|
||||
for (const walletDef of WALLET_DEFS) {
|
||||
const toEnable = walletDef.name === name
|
||||
const key = getStorageKey(name, me)
|
||||
const config = JSON.parse(window.localStorage.getItem(key))
|
||||
if (config.enabled || toEnable) {
|
||||
config.enabled = toEnable
|
||||
window.localStorage.setItem(key, JSON.stringify(config))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function disableWallet (name, me) {
|
||||
const key = getStorageKey(name, me)
|
||||
const config = JSON.parse(window.localStorage.getItem(key))
|
||||
if (!config) return
|
||||
config.enabled = false
|
||||
window.localStorage.setItem(key, JSON.stringify(config))
|
||||
}
|
144
components/wallet/lnbits.js
Normal file
144
components/wallet/lnbits.js
Normal file
@ -0,0 +1,144 @@
|
||||
import { TOR_REGEXP } from '@/lib/url'
|
||||
import { object, string } from 'yup'
|
||||
|
||||
export const name = 'lnbits'
|
||||
|
||||
export const fields = [
|
||||
{
|
||||
name: 'url',
|
||||
label: 'lnbits url',
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'adminKey',
|
||||
label: 'admin key',
|
||||
type: 'password'
|
||||
}
|
||||
]
|
||||
|
||||
export const card = {
|
||||
title: 'LNbits',
|
||||
subtitle: 'use LNbits for payments',
|
||||
badges: ['send only', 'non-custodialish']
|
||||
}
|
||||
|
||||
export async function validate ({ logger, ...config }) {
|
||||
return await getInfo({ logger, ...config })
|
||||
}
|
||||
|
||||
export const schema = 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()
|
||||
.test(async (url, context) => {
|
||||
if (TOR_REGEXP.test(url)) {
|
||||
// allow HTTP and HTTPS over Tor
|
||||
if (!/^https?:\/\//.test(url)) {
|
||||
return context.createError({ message: 'http or https required' })
|
||||
}
|
||||
return true
|
||||
}
|
||||
try {
|
||||
// force HTTPS over clearnet
|
||||
await string().https().validate(url)
|
||||
} catch (err) {
|
||||
return context.createError({ message: err.message })
|
||||
}
|
||||
return true
|
||||
}),
|
||||
adminKey: string().length(32)
|
||||
})
|
||||
|
||||
async function getInfo ({ logger, ...config }) {
|
||||
logger.info('trying to fetch wallet')
|
||||
const response = await getWallet(config.url, config.adminKey)
|
||||
logger.ok('wallet found')
|
||||
return {
|
||||
node: {
|
||||
alias: response.name,
|
||||
pubkey: ''
|
||||
},
|
||||
methods: [
|
||||
'getInfo',
|
||||
'getBalance',
|
||||
'sendPayment'
|
||||
],
|
||||
version: '1.0',
|
||||
supports: ['lightning']
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPayment ({ bolt11, config }) {
|
||||
const { url, adminKey } = config
|
||||
|
||||
const response = await postPayment(url, adminKey, bolt11)
|
||||
|
||||
const checkResponse = await getPayment(url, adminKey, response.payment_hash)
|
||||
if (!checkResponse.preimage) {
|
||||
throw new Error('No preimage')
|
||||
}
|
||||
|
||||
const preimage = checkResponse.preimage
|
||||
return { preimage }
|
||||
}
|
||||
|
||||
async function getWallet (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
|
||||
}
|
||||
|
||||
async function postPayment (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
|
||||
}
|
||||
|
||||
async function getPayment (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
|
||||
}
|
103
components/wallet/nwc.js
Normal file
103
components/wallet/nwc.js
Normal file
@ -0,0 +1,103 @@
|
||||
import { NOSTR_PUBKEY_HEX } from '@/lib/nostr'
|
||||
import { parseNwcUrl } from '@/lib/url'
|
||||
import { Relay } from 'nostr-tools'
|
||||
import { object, string } from 'yup'
|
||||
|
||||
export const name = 'nwc'
|
||||
|
||||
export const fields = [
|
||||
{
|
||||
name: 'nwcUrl',
|
||||
label: 'connection',
|
||||
type: 'password'
|
||||
}
|
||||
]
|
||||
|
||||
export const card = {
|
||||
title: 'NWC',
|
||||
subtitle: 'use Nostr Wallet Connect for payments',
|
||||
badges: ['send only', 'non-custodialish']
|
||||
}
|
||||
|
||||
export const schema = object({
|
||||
nwcUrl: string()
|
||||
.required('required')
|
||||
.test(async (nwcUrl, context) => {
|
||||
// run validation in sequence to control order of errors
|
||||
// inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180
|
||||
try {
|
||||
await string().required('required').validate(nwcUrl)
|
||||
await string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validate(nwcUrl)
|
||||
let relayUrl, walletPubkey, secret
|
||||
try {
|
||||
({ relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl))
|
||||
} catch {
|
||||
// invalid URL error. handle as if pubkey validation failed to not confuse user.
|
||||
throw new Error('pubkey must be 64 hex chars')
|
||||
}
|
||||
await string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validate(walletPubkey)
|
||||
await string().required('relay url required').trim().wss('relay must use wss://').validate(relayUrl)
|
||||
await string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validate(secret)
|
||||
} catch (err) {
|
||||
return context.createError({ message: err.message })
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
export async function validate ({ logger, ...config }) {
|
||||
return await getInfo({ logger, ...config })
|
||||
}
|
||||
|
||||
async function getInfo ({ logger, nwcUrl }) {
|
||||
const { relayUrl, walletPubkey } = parseNwcUrl(nwcUrl)
|
||||
|
||||
logger.info(`requesting info event from ${relayUrl}`)
|
||||
const relay = await Relay
|
||||
.connect(relayUrl)
|
||||
.catch(() => {
|
||||
// NOTE: passed error is undefined for some reason
|
||||
const msg = `failed to connect to ${relayUrl}`
|
||||
logger.error(msg)
|
||||
throw new Error(msg)
|
||||
})
|
||||
logger.ok(`connected to ${relayUrl}`)
|
||||
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
let found = false
|
||||
const sub = relay.subscribe([
|
||||
{
|
||||
kinds: [13194],
|
||||
authors: [walletPubkey]
|
||||
}
|
||||
], {
|
||||
onevent (event) {
|
||||
found = true
|
||||
logger.ok(`received info event from ${relayUrl}`)
|
||||
resolve(event)
|
||||
},
|
||||
onclose (reason) {
|
||||
if (!['closed by caller', 'relay connection closed by us'].includes(reason)) {
|
||||
// only log if not closed by us (caller)
|
||||
const msg = 'connection closed: ' + (reason || 'unknown reason')
|
||||
logger.error(msg)
|
||||
reject(new Error(msg))
|
||||
}
|
||||
},
|
||||
oneose () {
|
||||
if (!found) {
|
||||
const msg = 'EOSE received without info event'
|
||||
logger.error(msg)
|
||||
reject(new Error(msg))
|
||||
}
|
||||
sub?.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
// For some reason, websocket is already in CLOSING or CLOSED state.
|
||||
// relay?.close()
|
||||
if (relay) logger.info(`closed connection to ${relayUrl}`)
|
||||
}
|
||||
}
|
@ -1,142 +0,0 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { LNbitsProvider, useLNbits } from './lnbits'
|
||||
import { NWCProvider, useNWC } from './nwc'
|
||||
import { LNCProvider, useLNC } from './lnc'
|
||||
|
||||
const WebLNContext = createContext({})
|
||||
|
||||
const isEnabled = p => [Status.Enabled, Status.Locked].includes(p?.status)
|
||||
|
||||
const syncProvider = (array, provider) => {
|
||||
const idx = array.findIndex(({ name }) => provider.name === name)
|
||||
const enabled = isEnabled(provider)
|
||||
if (idx === -1) {
|
||||
// add provider to end if enabled
|
||||
return enabled ? [...array, provider] : array
|
||||
}
|
||||
return [
|
||||
...array.slice(0, idx),
|
||||
// remove provider if not enabled
|
||||
...enabled ? [provider] : [],
|
||||
...array.slice(idx + 1)
|
||||
]
|
||||
}
|
||||
|
||||
const storageKey = 'webln:providers'
|
||||
|
||||
export const Status = {
|
||||
Initialized: 'Initialized',
|
||||
Enabled: 'Enabled',
|
||||
Locked: 'Locked',
|
||||
Error: 'Error'
|
||||
}
|
||||
|
||||
export function migrateLocalStorage (oldStorageKey, newStorageKey) {
|
||||
const item = window.localStorage.getItem(oldStorageKey)
|
||||
if (item) {
|
||||
window.localStorage.setItem(newStorageKey, item)
|
||||
window.localStorage.removeItem(oldStorageKey)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
function RawWebLNProvider ({ children }) {
|
||||
const lnbits = useLNbits()
|
||||
const nwc = useNWC()
|
||||
const lnc = useLNC()
|
||||
const availableProviders = [lnbits, nwc, lnc]
|
||||
const [enabledProviders, setEnabledProviders] = useState([])
|
||||
|
||||
// restore order on page reload
|
||||
useEffect(() => {
|
||||
const storedOrder = window.localStorage.getItem(storageKey)
|
||||
if (!storedOrder) return
|
||||
const providerNames = JSON.parse(storedOrder)
|
||||
setEnabledProviders(providers => {
|
||||
return providerNames.map(name => {
|
||||
for (const p of availableProviders) {
|
||||
if (p.name === name) return p
|
||||
}
|
||||
console.warn(`Stored provider with name ${name} not available`)
|
||||
return null
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
// keep list in sync with underlying providers
|
||||
useEffect(() => {
|
||||
setEnabledProviders(providers => {
|
||||
// Sync existing provider state with new provider state
|
||||
// in the list while keeping the order they are in.
|
||||
// If provider does not exist but is enabled, it is just added to the end of the list.
|
||||
// This can be the case if we're syncing from a page reload
|
||||
// where the providers are initially not enabled.
|
||||
// If provider is no longer enabled, it is removed from the list.
|
||||
const isInitialized = p => [Status.Enabled, Status.Locked, Status.Initialized].includes(p.status)
|
||||
const newProviders = availableProviders.filter(isInitialized).reduce(syncProvider, providers)
|
||||
const newOrder = newProviders.map(({ name }) => name)
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(newOrder))
|
||||
return newProviders
|
||||
})
|
||||
}, [...availableProviders])
|
||||
|
||||
// first provider in list is the default provider
|
||||
// TODO: implement fallbacks via provider priority
|
||||
const provider = enabledProviders[0]
|
||||
|
||||
const setProvider = useCallback((defaultProvider) => {
|
||||
// move provider to the start to set it as default
|
||||
setEnabledProviders(providers => {
|
||||
const idx = providers.findIndex(({ name }) => defaultProvider.name === name)
|
||||
if (idx === -1) {
|
||||
console.warn(`tried to set unenabled provider ${defaultProvider.name} as default`)
|
||||
return providers
|
||||
}
|
||||
return [defaultProvider, ...providers.slice(0, idx), ...providers.slice(idx + 1)]
|
||||
})
|
||||
}, [setEnabledProviders])
|
||||
|
||||
const clearConfig = useCallback(async () => {
|
||||
lnbits.clearConfig()
|
||||
nwc.clearConfig()
|
||||
await lnc.clearConfig()
|
||||
}, [])
|
||||
|
||||
const value = useMemo(() => ({
|
||||
provider: isEnabled(provider)
|
||||
? { name: provider.name, sendPayment: provider.sendPayment }
|
||||
: null,
|
||||
enabledProviders,
|
||||
setProvider,
|
||||
clearConfig
|
||||
}), [provider, enabledProviders, setProvider])
|
||||
|
||||
return (
|
||||
<WebLNContext.Provider value={value}>
|
||||
{children}
|
||||
</WebLNContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function WebLNProvider ({ children }) {
|
||||
return (
|
||||
<LNbitsProvider>
|
||||
<NWCProvider>
|
||||
<LNCProvider>
|
||||
<RawWebLNProvider>
|
||||
{children}
|
||||
</RawWebLNProvider>
|
||||
</LNCProvider>
|
||||
</NWCProvider>
|
||||
</LNbitsProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useWebLN () {
|
||||
const { provider } = useContext(WebLNContext)
|
||||
return provider
|
||||
}
|
||||
|
||||
export function useWebLNConfigurator () {
|
||||
return useContext(WebLNContext)
|
||||
}
|
@ -1,210 +0,0 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { useWalletLogger } from '../logger'
|
||||
import { Status, migrateLocalStorage } from '.'
|
||||
import { bolt11Tags } from '@/lib/bolt11'
|
||||
import { Wallet } from '@/lib/constants'
|
||||
import { useMe } from '../me'
|
||||
|
||||
// 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 me = useMe()
|
||||
const [url, setUrl] = useState('')
|
||||
const [adminKey, setAdminKey] = useState('')
|
||||
const [status, setStatus] = useState()
|
||||
const { logger } = useWalletLogger(Wallet.LNbits)
|
||||
|
||||
let storageKey = 'webln:provider:lnbits'
|
||||
if (me) {
|
||||
storageKey = `${storageKey}:${me.id}`
|
||||
}
|
||||
|
||||
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 hash = bolt11Tags(bolt11).payment_hash
|
||||
logger.info('sending payment:', `payment_hash=${hash}`)
|
||||
|
||||
try {
|
||||
const response = await postPayment(url, adminKey, bolt11)
|
||||
const checkResponse = await getPayment(url, adminKey, response.payment_hash)
|
||||
if (!checkResponse.preimage) {
|
||||
throw new Error('No preimage')
|
||||
}
|
||||
const preimage = checkResponse.preimage
|
||||
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
|
||||
return { preimage }
|
||||
} catch (err) {
|
||||
logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.())
|
||||
throw err
|
||||
}
|
||||
}, [logger, url, adminKey])
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
let configStr = window.localStorage.getItem(storageKey)
|
||||
setStatus(Status.Initialized)
|
||||
if (!configStr) {
|
||||
if (me) {
|
||||
// backwards compatibility: try old storageKey
|
||||
const oldStorageKey = storageKey.split(':').slice(0, -1).join(':')
|
||||
configStr = migrateLocalStorage(oldStorageKey, storageKey)
|
||||
}
|
||||
if (!configStr) {
|
||||
logger.info('no existing config found')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const config = JSON.parse(configStr)
|
||||
|
||||
const { url, adminKey } = config
|
||||
setUrl(url)
|
||||
setAdminKey(adminKey)
|
||||
|
||||
logger.info(
|
||||
'loaded wallet config: ' +
|
||||
'adminKey=****** ' +
|
||||
`url=${url}`)
|
||||
|
||||
try {
|
||||
// validate config by trying to fetch wallet
|
||||
logger.info('trying to fetch wallet')
|
||||
await getWallet(url, adminKey)
|
||||
logger.ok('wallet found')
|
||||
setStatus(Status.Enabled)
|
||||
logger.ok('wallet enabled')
|
||||
} catch (err) {
|
||||
logger.error('invalid config:', err)
|
||||
setStatus(Status.Error)
|
||||
logger.info('wallet disabled')
|
||||
throw err
|
||||
}
|
||||
}, [me, logger])
|
||||
|
||||
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))
|
||||
|
||||
logger.info(
|
||||
'saved wallet config: ' +
|
||||
'adminKey=****** ' +
|
||||
`url=${config.url}`)
|
||||
|
||||
try {
|
||||
// validate config by trying to fetch wallet
|
||||
logger.info('trying to fetch wallet')
|
||||
await getWallet(config.url, config.adminKey)
|
||||
logger.ok('wallet found')
|
||||
setStatus(Status.Enabled)
|
||||
logger.ok('wallet enabled')
|
||||
} catch (err) {
|
||||
logger.error('invalid config:', err)
|
||||
setStatus(Status.Error)
|
||||
logger.info('wallet disabled')
|
||||
throw err
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clearConfig = useCallback(() => {
|
||||
window.localStorage.removeItem(storageKey)
|
||||
setUrl('')
|
||||
setAdminKey('')
|
||||
setStatus(undefined)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig().catch(console.error)
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ name: 'LNbits', url, adminKey, status, saveConfig, clearConfig, getInfo, sendPayment }),
|
||||
[url, adminKey, status, saveConfig, clearConfig, getInfo, sendPayment])
|
||||
return (
|
||||
<LNbitsContext.Provider value={value}>
|
||||
{children}
|
||||
</LNbitsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLNbits () {
|
||||
return useContext(LNbitsContext)
|
||||
}
|
@ -1,215 +0,0 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { useWalletLogger } from '../logger'
|
||||
import LNC from '@lightninglabs/lnc-web'
|
||||
import { Status, migrateLocalStorage } from '.'
|
||||
import { bolt11Tags } from '@/lib/bolt11'
|
||||
import useModal from '../modal'
|
||||
import { Form, PasswordInput, SubmitButton } from '../form'
|
||||
import CancelButton from '../cancel-button'
|
||||
import { Mutex } from 'async-mutex'
|
||||
import { Wallet } from '@/lib/constants'
|
||||
import { useMe } from '../me'
|
||||
import { InvoiceCanceledError, InvoiceExpiredError } from '@/components/payment'
|
||||
|
||||
const LNCContext = createContext()
|
||||
const mutex = new Mutex()
|
||||
|
||||
async function getLNC ({ me }) {
|
||||
if (window.lnc) return window.lnc
|
||||
// backwards compatibility: migrate to new storage key
|
||||
if (me) migrateLocalStorage('lnc-web:default', `lnc-web:stacker:${me.id}`)
|
||||
window.lnc = new LNC({ namespace: me?.id ? `stacker:${me.id}` : undefined })
|
||||
return window.lnc
|
||||
}
|
||||
|
||||
// default password if the user hasn't set one
|
||||
export const XXX_DEFAULT_PASSWORD = 'password'
|
||||
|
||||
function validateNarrowPerms (lnc) {
|
||||
if (!lnc.hasPerms('lnrpc.Lightning.SendPaymentSync')) {
|
||||
throw new Error('missing permission: lnrpc.Lightning.SendPaymentSync')
|
||||
}
|
||||
if (lnc.hasPerms('lnrpc.Lightning.SendCoins')) {
|
||||
throw new Error('too broad permission: lnrpc.Wallet.SendCoins')
|
||||
}
|
||||
// TODO: need to check for more narrow permissions
|
||||
// blocked by https://github.com/lightninglabs/lnc-web/issues/112
|
||||
}
|
||||
|
||||
export function LNCProvider ({ children }) {
|
||||
const me = useMe()
|
||||
const { logger } = useWalletLogger(Wallet.LNC)
|
||||
const [config, setConfig] = useState({})
|
||||
const [lnc, setLNC] = useState()
|
||||
const [status, setStatus] = useState()
|
||||
const [modal, showModal] = useModal()
|
||||
|
||||
const getInfo = useCallback(async () => {
|
||||
logger.info('getInfo called')
|
||||
return await lnc.lightning.getInfo()
|
||||
}, [logger, lnc])
|
||||
|
||||
const unlock = useCallback(async (connect) => {
|
||||
if (status === Status.Enabled) return config.password
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const cancelAndReject = async () => {
|
||||
reject(new Error('password canceled'))
|
||||
}
|
||||
showModal(onClose => {
|
||||
return (
|
||||
<Form
|
||||
initial={{
|
||||
password: ''
|
||||
}}
|
||||
onSubmit={async (values) => {
|
||||
try {
|
||||
lnc.credentials.password = values?.password
|
||||
setStatus(Status.Enabled)
|
||||
setConfig({ pairingPhrase: lnc.credentials.pairingPhrase, password: values.password })
|
||||
logger.ok('wallet enabled')
|
||||
onClose()
|
||||
resolve(values.password)
|
||||
} catch (err) {
|
||||
logger.error('failed attempt to unlock wallet', err)
|
||||
throw err
|
||||
}
|
||||
}}
|
||||
>
|
||||
<h4 className='text-center mb-3'>Unlock LNC</h4>
|
||||
<PasswordInput
|
||||
label='password'
|
||||
name='password'
|
||||
/>
|
||||
<div className='mt-5 d-flex justify-content-between'>
|
||||
<CancelButton onClick={() => { onClose(); cancelAndReject() }} />
|
||||
<SubmitButton variant='primary'>unlock</SubmitButton>
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
}, { onClose: cancelAndReject })
|
||||
})
|
||||
}, [logger, showModal, setConfig, lnc, status])
|
||||
|
||||
const sendPayment = useCallback(async (bolt11) => {
|
||||
const hash = bolt11Tags(bolt11).payment_hash
|
||||
logger.info('sending payment:', `payment_hash=${hash}`)
|
||||
|
||||
return await mutex.runExclusive(async () => {
|
||||
try {
|
||||
const password = await unlock()
|
||||
// credentials need to be decrypted before connecting after a disconnect
|
||||
lnc.credentials.password = password || XXX_DEFAULT_PASSWORD
|
||||
await lnc.connect()
|
||||
const { paymentError, paymentPreimage: preimage } =
|
||||
await lnc.lnd.lightning.sendPaymentSync({ payment_request: bolt11 })
|
||||
|
||||
if (paymentError) throw new Error(paymentError)
|
||||
if (!preimage) throw new Error('No preimage in response')
|
||||
|
||||
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
|
||||
return { preimage }
|
||||
} catch (err) {
|
||||
const msg = err.message || err.toString?.()
|
||||
logger.error('payment failed:', `payment_hash=${hash}`, msg)
|
||||
if (msg.includes('invoice expired')) {
|
||||
throw new InvoiceExpiredError(hash)
|
||||
}
|
||||
if (msg.includes('canceled')) {
|
||||
throw new InvoiceCanceledError(hash)
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
try {
|
||||
lnc.disconnect()
|
||||
logger.info('disconnecting after:', `payment_hash=${hash}`)
|
||||
// wait for lnc to disconnect before releasing the mutex
|
||||
await new Promise((resolve, reject) => {
|
||||
let counter = 0
|
||||
const interval = setInterval(() => {
|
||||
if (lnc.isConnected) {
|
||||
if (counter++ > 100) {
|
||||
logger.error('failed to disconnect from lnc')
|
||||
clearInterval(interval)
|
||||
reject(new Error('failed to disconnect from lnc'))
|
||||
}
|
||||
return
|
||||
}
|
||||
clearInterval(interval)
|
||||
resolve()
|
||||
})
|
||||
}, 50)
|
||||
} catch (err) {
|
||||
logger.error('failed to disconnect from lnc', err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [logger, lnc, unlock])
|
||||
|
||||
const saveConfig = useCallback(async config => {
|
||||
setConfig(config)
|
||||
|
||||
try {
|
||||
lnc.credentials.pairingPhrase = config.pairingPhrase
|
||||
await lnc.connect()
|
||||
await validateNarrowPerms(lnc)
|
||||
lnc.credentials.password = config?.password || XXX_DEFAULT_PASSWORD
|
||||
setStatus(Status.Enabled)
|
||||
logger.ok('wallet enabled')
|
||||
} catch (err) {
|
||||
logger.error('invalid config:', err)
|
||||
setStatus(Status.Error)
|
||||
logger.info('wallet disabled')
|
||||
throw err
|
||||
} finally {
|
||||
lnc.disconnect()
|
||||
}
|
||||
}, [logger, lnc])
|
||||
|
||||
const clearConfig = useCallback(async () => {
|
||||
await lnc.credentials.clear(false)
|
||||
if (lnc.isConnected) lnc.disconnect()
|
||||
setStatus(undefined)
|
||||
setConfig({})
|
||||
logger.info('cleared config')
|
||||
}, [logger, lnc])
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const lnc = await getLNC({ me })
|
||||
setLNC(lnc)
|
||||
setStatus(Status.Initialized)
|
||||
if (lnc.credentials.isPaired) {
|
||||
try {
|
||||
// try the default password
|
||||
lnc.credentials.password = XXX_DEFAULT_PASSWORD
|
||||
} catch (err) {
|
||||
setStatus(Status.Locked)
|
||||
logger.info('wallet needs password before enabling')
|
||||
return
|
||||
}
|
||||
setStatus(Status.Enabled)
|
||||
setConfig({ pairingPhrase: lnc.credentials.pairingPhrase, password: lnc.credentials.password })
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('wallet could not be loaded:', err)
|
||||
setStatus(Status.Error)
|
||||
}
|
||||
})()
|
||||
}, [me, setStatus, setConfig, logger])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ name: 'lnc', status, unlock, getInfo, sendPayment, config, saveConfig, clearConfig }),
|
||||
[status, unlock, getInfo, sendPayment, config, saveConfig, clearConfig])
|
||||
return (
|
||||
<LNCContext.Provider value={value}>
|
||||
{children}
|
||||
{modal}
|
||||
</LNCContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLNC () {
|
||||
return useContext(LNCContext)
|
||||
}
|
@ -1,288 +0,0 @@
|
||||
// https://github.com/getAlby/js-sdk/blob/master/src/webln/NostrWeblnProvider.ts
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { Relay, finalizeEvent, nip04 } from 'nostr-tools'
|
||||
import { parseNwcUrl } from '@/lib/url'
|
||||
import { useWalletLogger } from '../logger'
|
||||
import { Status, migrateLocalStorage } from '.'
|
||||
import { bolt11Tags } from '@/lib/bolt11'
|
||||
import { JIT_INVOICE_TIMEOUT_MS, Wallet } from '@/lib/constants'
|
||||
import { useMe } from '../me'
|
||||
import { InvoiceExpiredError } from '../payment'
|
||||
|
||||
const NWCContext = createContext()
|
||||
|
||||
export function NWCProvider ({ children }) {
|
||||
const me = useMe()
|
||||
const [nwcUrl, setNwcUrl] = useState('')
|
||||
const [walletPubkey, setWalletPubkey] = useState()
|
||||
const [relayUrl, setRelayUrl] = useState()
|
||||
const [secret, setSecret] = useState()
|
||||
const [status, setStatus] = useState()
|
||||
const { logger } = useWalletLogger(Wallet.NWC)
|
||||
|
||||
let storageKey = 'webln:provider:nwc'
|
||||
if (me) {
|
||||
storageKey = `${storageKey}:${me.id}`
|
||||
}
|
||||
|
||||
const getInfo = useCallback(async (relayUrl, walletPubkey) => {
|
||||
logger.info(`requesting info event from ${relayUrl}`)
|
||||
|
||||
let relay, sub
|
||||
try {
|
||||
relay = await Relay.connect(relayUrl).catch(() => {
|
||||
// NOTE: passed error is undefined for some reason
|
||||
const msg = `failed to connect to ${relayUrl}`
|
||||
logger.error(msg)
|
||||
throw new Error(msg)
|
||||
})
|
||||
logger.ok(`connected to ${relayUrl}`)
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timeout = 5000
|
||||
const timer = setTimeout(() => {
|
||||
const msg = 'timeout waiting for info event'
|
||||
logger.error(msg)
|
||||
reject(new Error(msg))
|
||||
sub?.close()
|
||||
}, timeout)
|
||||
|
||||
let found = false
|
||||
sub = relay.subscribe([
|
||||
{
|
||||
kinds: [13194],
|
||||
authors: [walletPubkey]
|
||||
}
|
||||
], {
|
||||
onevent (event) {
|
||||
clearTimeout(timer)
|
||||
found = true
|
||||
logger.ok(`received info event from ${relayUrl}`)
|
||||
resolve(event)
|
||||
},
|
||||
onclose (reason) {
|
||||
clearTimeout(timer)
|
||||
if (!['closed by caller', 'relay connection closed by us'].includes(reason)) {
|
||||
// only log if not closed by us (caller)
|
||||
const msg = 'connection closed: ' + (reason || 'unknown reason')
|
||||
logger.error(msg)
|
||||
reject(new Error(msg))
|
||||
}
|
||||
},
|
||||
oneose () {
|
||||
clearTimeout(timer)
|
||||
if (!found) {
|
||||
const msg = 'EOSE received without info event'
|
||||
logger.error(msg)
|
||||
reject(new Error(msg))
|
||||
}
|
||||
sub?.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
// For some reason, websocket is already in CLOSING or CLOSED state.
|
||||
// relay?.close()
|
||||
if (relay) logger.info(`closed connection to ${relayUrl}`)
|
||||
}
|
||||
}, [logger])
|
||||
|
||||
const validateParams = useCallback(async ({ relayUrl, walletPubkey }) => {
|
||||
// validate connection by fetching info event
|
||||
// function needs to throw an error for formik validation to fail
|
||||
const event = await getInfo(relayUrl, walletPubkey)
|
||||
const supported = event.content.split(/[\s,]+/) // handle both spaces and commas
|
||||
logger.info('supported methods:', supported)
|
||||
if (!supported.includes('pay_invoice')) {
|
||||
const msg = 'wallet does not support pay_invoice'
|
||||
logger.error(msg)
|
||||
throw new Error(msg)
|
||||
}
|
||||
logger.ok('wallet supports pay_invoice')
|
||||
}, [logger])
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
let configStr = window.localStorage.getItem(storageKey)
|
||||
setStatus(Status.Initialized)
|
||||
if (!configStr) {
|
||||
if (me) {
|
||||
// backwards compatibility: try old storageKey
|
||||
const oldStorageKey = storageKey.split(':').slice(0, -1).join(':')
|
||||
configStr = migrateLocalStorage(oldStorageKey, storageKey)
|
||||
}
|
||||
if (!configStr) {
|
||||
logger.info('no existing config found')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const config = JSON.parse(configStr)
|
||||
|
||||
const { nwcUrl } = config
|
||||
setNwcUrl(nwcUrl)
|
||||
|
||||
const params = parseNwcUrl(nwcUrl)
|
||||
setRelayUrl(params.relayUrl)
|
||||
setWalletPubkey(params.walletPubkey)
|
||||
setSecret(params.secret)
|
||||
|
||||
logger.info(
|
||||
'loaded wallet config: ' +
|
||||
'secret=****** ' +
|
||||
`pubkey=${params.walletPubkey.slice(0, 6)}..${params.walletPubkey.slice(-6)} ` +
|
||||
`relay=${params.relayUrl}`)
|
||||
|
||||
try {
|
||||
await validateParams(params)
|
||||
setStatus(Status.Enabled)
|
||||
logger.ok('wallet enabled')
|
||||
} catch (err) {
|
||||
logger.error('invalid config:', err)
|
||||
setStatus(Status.Error)
|
||||
logger.info('wallet disabled')
|
||||
throw err
|
||||
}
|
||||
}, [me, validateParams, logger])
|
||||
|
||||
const saveConfig = useCallback(async (config) => {
|
||||
// immediately store config so it's not lost even if config is invalid
|
||||
const { nwcUrl } = config
|
||||
setNwcUrl(nwcUrl)
|
||||
if (!nwcUrl) {
|
||||
setStatus(undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const params = parseNwcUrl(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))
|
||||
|
||||
logger.info(
|
||||
'saved wallet config: ' +
|
||||
'secret=****** ' +
|
||||
`pubkey=${params.walletPubkey.slice(0, 6)}..${params.walletPubkey.slice(-6)} ` +
|
||||
`relay=${params.relayUrl}`)
|
||||
|
||||
try {
|
||||
await validateParams(params)
|
||||
setStatus(Status.Enabled)
|
||||
logger.ok('wallet enabled')
|
||||
} catch (err) {
|
||||
logger.error('invalid config:', err)
|
||||
setStatus(Status.Error)
|
||||
logger.info('wallet disabled')
|
||||
throw err
|
||||
}
|
||||
}, [validateParams, logger])
|
||||
|
||||
const clearConfig = useCallback(() => {
|
||||
window.localStorage.removeItem(storageKey)
|
||||
setNwcUrl('')
|
||||
setRelayUrl(undefined)
|
||||
setWalletPubkey(undefined)
|
||||
setSecret(undefined)
|
||||
setStatus(undefined)
|
||||
}, [])
|
||||
|
||||
const sendPayment = useCallback(async (bolt11) => {
|
||||
const hash = bolt11Tags(bolt11).payment_hash
|
||||
logger.info('sending payment:', `payment_hash=${hash}`)
|
||||
|
||||
let relay, sub
|
||||
try {
|
||||
relay = await Relay.connect(relayUrl).catch(() => {
|
||||
// NOTE: passed error is undefined for some reason
|
||||
const msg = `failed to connect to ${relayUrl}`
|
||||
logger.error(msg)
|
||||
throw new Error(msg)
|
||||
})
|
||||
logger.ok(`connected to ${relayUrl}`)
|
||||
const ret = await new Promise(function (resolve, reject) {
|
||||
(async function () {
|
||||
// timeout since NWC is async (user needs to confirm payment in wallet)
|
||||
// timeout is same as invoice expiry
|
||||
const timeout = JIT_INVOICE_TIMEOUT_MS
|
||||
const timer = setTimeout(() => {
|
||||
const msg = 'timeout waiting for payment'
|
||||
logger.error(msg)
|
||||
reject(new InvoiceExpiredError(hash))
|
||||
sub?.close()
|
||||
}, timeout)
|
||||
|
||||
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)
|
||||
|
||||
const filter = {
|
||||
kinds: [23195],
|
||||
authors: [walletPubkey],
|
||||
'#e': [request.id]
|
||||
}
|
||||
sub = relay.subscribe([filter], {
|
||||
async onevent (response) {
|
||||
clearTimeout(timer)
|
||||
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)
|
||||
}
|
||||
},
|
||||
onclose (reason) {
|
||||
clearTimeout(timer)
|
||||
if (!['closed by caller', 'relay connection closed by us'].includes(reason)) {
|
||||
// only log if not closed by us (caller)
|
||||
const msg = 'connection closed: ' + (reason || 'unknown reason')
|
||||
logger.error(msg)
|
||||
reject(new Error(msg))
|
||||
}
|
||||
}
|
||||
})
|
||||
})().catch(reject)
|
||||
})
|
||||
const preimage = ret.preimage
|
||||
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
|
||||
return ret
|
||||
} catch (err) {
|
||||
logger.error('payment failed:', `payment_hash=${hash}`, err.message || err.toString?.())
|
||||
throw err
|
||||
} finally {
|
||||
// For some reason, websocket is already in CLOSING or CLOSED state.
|
||||
// relay?.close()
|
||||
if (relay) logger.info(`closed connection to ${relayUrl}`)
|
||||
}
|
||||
}, [walletPubkey, relayUrl, secret, logger])
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig().catch(err => logger.error(err.message || err.toString?.()))
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ name: 'NWC', nwcUrl, relayUrl, walletPubkey, secret, status, saveConfig, clearConfig, getInfo, sendPayment }),
|
||||
[nwcUrl, relayUrl, walletPubkey, secret, status, saveConfig, clearConfig, getInfo, sendPayment])
|
||||
return (
|
||||
<NWCContext.Provider value={value}>
|
||||
{children}
|
||||
</NWCContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useNWC () {
|
||||
return useContext(NWCContext)
|
||||
}
|
@ -10,7 +10,7 @@ import { msatsToSats, numWithUnits, abbrNum, ensureB64, B64_URL_REGEX } from './
|
||||
import * as usersFragments from '@/fragments/users'
|
||||
import * as subsFragments from '@/fragments/subs'
|
||||
import { isInvoicableMacaroon, isInvoiceMacaroon } from './macaroon'
|
||||
import { TOR_REGEXP, parseNwcUrl } from './url'
|
||||
import { parseNwcUrl } from './url'
|
||||
import { datePivot } from './time'
|
||||
import { decodeRune } from '@/lib/cln'
|
||||
import bip39Words from './bip39-words'
|
||||
@ -600,57 +600,6 @@ 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()
|
||||
.test(async (url, context) => {
|
||||
if (TOR_REGEXP.test(url)) {
|
||||
// allow HTTP and HTTPS over Tor
|
||||
if (!/^https?:\/\//.test(url)) {
|
||||
return context.createError({ message: 'http or https required' })
|
||||
}
|
||||
return true
|
||||
}
|
||||
try {
|
||||
// force HTTPS over clearnet
|
||||
await string().https().validate(url)
|
||||
} catch (err) {
|
||||
return context.createError({ message: err.message })
|
||||
}
|
||||
return true
|
||||
}),
|
||||
adminKey: string().length(32)
|
||||
})
|
||||
|
||||
export const nwcSchema = object({
|
||||
nwcUrl: string()
|
||||
.required('required')
|
||||
.test(async (nwcUrl, context) => {
|
||||
// run validation in sequence to control order of errors
|
||||
// inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180
|
||||
try {
|
||||
await string().required('required').validate(nwcUrl)
|
||||
await string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validate(nwcUrl)
|
||||
let relayUrl, walletPubkey, secret
|
||||
try {
|
||||
({ relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl))
|
||||
} catch {
|
||||
// invalid URL error. handle as if pubkey validation failed to not confuse user.
|
||||
throw new Error('pubkey must be 64 hex chars')
|
||||
}
|
||||
await string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validate(walletPubkey)
|
||||
await string().required('relay url required').trim().wss('relay must use wss://').validate(relayUrl)
|
||||
await string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validate(secret)
|
||||
} catch (err) {
|
||||
return context.createError({ message: err.message })
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
export const lncSchema = object({
|
||||
pairingPhrase: array()
|
||||
.transform(function (value, originalValue) {
|
||||
|
@ -17,8 +17,8 @@ import { SSR } from '@/lib/constants'
|
||||
import NProgress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
import { LoggerProvider } from '@/components/logger'
|
||||
import { WalletLoggerProvider } from '@/components/wallet-logger'
|
||||
import { ChainFeeProvider } from '@/components/chain-fee.js'
|
||||
import { WebLNProvider } from '@/components/webln'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { HasNewNotesProvider } from '@/components/use-has-new-notes'
|
||||
import { ClientNotificationProvider } from '@/components/client-notifications'
|
||||
@ -107,11 +107,11 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
|
||||
<HasNewNotesProvider>
|
||||
<ClientNotificationProvider>
|
||||
<LoggerProvider>
|
||||
<ServiceWorkerProvider>
|
||||
<PriceProvider price={price}>
|
||||
<LightningProvider>
|
||||
<ToastProvider>
|
||||
<WebLNProvider>
|
||||
<WalletLoggerProvider>
|
||||
<ServiceWorkerProvider>
|
||||
<PriceProvider price={price}>
|
||||
<LightningProvider>
|
||||
<ToastProvider>
|
||||
<ShowModalProvider>
|
||||
<BlockHeightProvider blockHeight={blockHeight}>
|
||||
<ChainFeeProvider chainFee={chainFee}>
|
||||
@ -122,11 +122,11 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
|
||||
</ChainFeeProvider>
|
||||
</BlockHeightProvider>
|
||||
</ShowModalProvider>
|
||||
</WebLNProvider>
|
||||
</ToastProvider>
|
||||
</LightningProvider>
|
||||
</PriceProvider>
|
||||
</ServiceWorkerProvider>
|
||||
</ToastProvider>
|
||||
</LightningProvider>
|
||||
</PriceProvider>
|
||||
</ServiceWorkerProvider>
|
||||
</WalletLoggerProvider>
|
||||
</LoggerProvider>
|
||||
</ClientNotificationProvider>
|
||||
</HasNewNotesProvider>
|
||||
|
98
pages/settings/wallets/[wallet].js
Normal file
98
pages/settings/wallets/[wallet].js
Normal file
@ -0,0 +1,98 @@
|
||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||
import { Form, ClientInput, ClientCheckbox, PasswordInput } from '@/components/form'
|
||||
import { CenterLayout } from '@/components/layout'
|
||||
import { WalletButtonBar } from '@/components/wallet-card'
|
||||
import { WalletSecurityBanner } from '@/components/banners'
|
||||
import { WalletLogs } from '@/components/wallet-logger'
|
||||
import { useToast } from '@/components/toast'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useWallet, Status } from '@/components/wallet'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
|
||||
|
||||
export default function WalletSettings () {
|
||||
const toaster = useToast()
|
||||
const router = useRouter()
|
||||
const { wallet: name } = router.query
|
||||
const wallet = useWallet(name)
|
||||
|
||||
const initial = wallet.fields.reduce((acc, field) => {
|
||||
return {
|
||||
...acc,
|
||||
[field.name]: wallet.config?.[field.name] || ''
|
||||
}
|
||||
}, {
|
||||
isDefault: wallet.isDefault || false
|
||||
})
|
||||
|
||||
return (
|
||||
<CenterLayout>
|
||||
<h2 className='pb-2'>{wallet.card.title}</h2>
|
||||
<h6 className='text-muted text-center pb-3'>{wallet.card.subtitle}</h6>
|
||||
<WalletSecurityBanner />
|
||||
<Form
|
||||
initial={initial}
|
||||
schema={wallet.schema}
|
||||
onSubmit={async ({ enabled, ...values }) => {
|
||||
try {
|
||||
const newConfig = !wallet.isConfigured
|
||||
await wallet.validate(values)
|
||||
wallet.save(values)
|
||||
// enable wallet if checkbox was set or if wallet was just configured
|
||||
if (enabled || newConfig) wallet.enable()
|
||||
else wallet.disable()
|
||||
toaster.success('saved settings')
|
||||
router.push('/settings/wallets')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
const message = 'failed to attach: ' + err.message || err.toString?.()
|
||||
toaster.danger(message)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<WalletFields wallet={wallet} />
|
||||
<ClientCheckbox
|
||||
disabled={false}
|
||||
initialValue={wallet.status === Status.Enabled}
|
||||
label='enabled'
|
||||
name='enabled'
|
||||
/>
|
||||
<WalletButtonBar
|
||||
wallet={wallet} onDelete={async () => {
|
||||
try {
|
||||
wallet.delete()
|
||||
toaster.success('saved settings')
|
||||
router.push('/settings/wallets')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
const message = 'failed to detach: ' + err.message || err.toString?.()
|
||||
toaster.danger(message)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form>
|
||||
<div className='mt-3 w-100'>
|
||||
<WalletLogs wallet={wallet} embedded />
|
||||
</div>
|
||||
</CenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function WalletFields ({ wallet: { config, fields } }) {
|
||||
return fields.map(({ name, label, type }, i) => {
|
||||
const props = {
|
||||
initialValue: config?.[name],
|
||||
label,
|
||||
name,
|
||||
required: true,
|
||||
autoFocus: i === 0
|
||||
}
|
||||
if (type === 'text') {
|
||||
return <ClientInput key={i} {...props} />
|
||||
}
|
||||
if (type === 'password') {
|
||||
return <PasswordInput key={i} {...props} newPass />
|
||||
}
|
||||
return null
|
||||
})
|
||||
}
|
@ -1,137 +0,0 @@
|
||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||
import { Form, Input } from '@/components/form'
|
||||
import { CenterLayout } from '@/components/layout'
|
||||
import { useMe } from '@/components/me'
|
||||
import { WalletButtonBar, WalletCard } from '@/components/wallet-card'
|
||||
import { useApolloClient, useMutation } from '@apollo/client'
|
||||
import { useToast } from '@/components/toast'
|
||||
import { CLNAutowithdrawSchema } from '@/lib/validate'
|
||||
import { useRouter } from 'next/router'
|
||||
import { AutowithdrawSettings, autowithdrawInitial } from '@/components/autowithdraw-shared'
|
||||
import { REMOVE_WALLET, UPSERT_WALLET_CLN, WALLET_BY_TYPE } from '@/fragments/wallet'
|
||||
import WalletLogs from '@/components/wallet-logs'
|
||||
import Info from '@/components/info'
|
||||
import Text from '@/components/text'
|
||||
import { Wallet } from '@/lib/constants'
|
||||
|
||||
const variables = { type: Wallet.CLN.type }
|
||||
export const getServerSideProps = getGetServerSideProps({ query: WALLET_BY_TYPE, variables, authRequired: true })
|
||||
|
||||
export default function CLN ({ ssrData }) {
|
||||
const me = useMe()
|
||||
const toaster = useToast()
|
||||
const router = useRouter()
|
||||
const client = useApolloClient()
|
||||
const [upsertWalletCLN] = useMutation(UPSERT_WALLET_CLN, {
|
||||
refetchQueries: ['WalletLogs'],
|
||||
onError: (err) => {
|
||||
client.refetchQueries({ include: ['WalletLogs'] })
|
||||
throw err
|
||||
}
|
||||
})
|
||||
const [removeWallet] = useMutation(REMOVE_WALLET, {
|
||||
refetchQueries: ['WalletLogs'],
|
||||
onError: (err) => {
|
||||
client.refetchQueries({ include: ['WalletLogs'] })
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
const { walletByType: wallet } = ssrData || {}
|
||||
|
||||
return (
|
||||
<CenterLayout>
|
||||
<h2 className='pb-2'>CLN</h2>
|
||||
<h6 className='text-muted text-center'>autowithdraw to your Core Lightning node via <a href='https://docs.corelightning.org/docs/rest' target='_blank' noreferrer rel='noreferrer'>CLNRest</a></h6>
|
||||
<Form
|
||||
initial={{
|
||||
socket: wallet?.wallet?.socket || '',
|
||||
rune: wallet?.wallet?.rune || '',
|
||||
cert: wallet?.wallet?.cert || '',
|
||||
...autowithdrawInitial({ me, priority: wallet?.priority })
|
||||
}}
|
||||
schema={CLNAutowithdrawSchema({ me })}
|
||||
onSubmit={async ({ socket, rune, cert, ...settings }) => {
|
||||
try {
|
||||
await upsertWalletCLN({
|
||||
variables: {
|
||||
id: wallet?.id,
|
||||
socket,
|
||||
rune,
|
||||
cert,
|
||||
settings: {
|
||||
...settings,
|
||||
autoWithdrawThreshold: Number(settings.autoWithdrawThreshold),
|
||||
autoWithdrawMaxFeePercent: Number(settings.autoWithdrawMaxFeePercent)
|
||||
}
|
||||
}
|
||||
})
|
||||
toaster.success('saved settings')
|
||||
router.push('/settings/wallets')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label='rest host and port'
|
||||
name='socket'
|
||||
hint='tor or clearnet'
|
||||
placeholder='55.5.555.55:3010'
|
||||
clear
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label={
|
||||
<div className='d-flex align-items-center'>invoice only rune
|
||||
<Info>
|
||||
<Text>
|
||||
{'We only accept runes that *only* allow `method=invoice`.\nRun this to generate one:\n\n```lightning-cli createrune restrictions=\'["method=invoice"]\'```'}
|
||||
</Text>
|
||||
</Info>
|
||||
</div>
|
||||
}
|
||||
name='rune'
|
||||
clear
|
||||
hint='must be restricted to method=invoice'
|
||||
placeholder='S34KtUW-6gqS_hD_9cc_PNhfF-NinZyBOCgr1aIrark9NCZtZXRob2Q9aW52b2ljZQ=='
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label={<>cert <small className='text-muted ms-2'>optional if from <a href='https://en.wikipedia.org/wiki/Certificate_authority' target='_blank' rel='noreferrer'>CA</a> (e.g. voltage)</small></>}
|
||||
name='cert'
|
||||
clear
|
||||
hint='hex or base64 encoded'
|
||||
placeholder='LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K'
|
||||
/>
|
||||
<AutowithdrawSettings />
|
||||
<WalletButtonBar
|
||||
status={!!wallet} onDelete={async () => {
|
||||
try {
|
||||
await removeWallet({ variables: { id: wallet?.id } })
|
||||
toaster.success('saved settings')
|
||||
router.push('/settings/wallets')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form>
|
||||
<div className='mt-3 w-100'>
|
||||
<WalletLogs wallet={Wallet.CLN} embedded />
|
||||
</div>
|
||||
</CenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export function CLNCard ({ wallet }) {
|
||||
return (
|
||||
<WalletCard
|
||||
title='CLN'
|
||||
badges={['receive only', 'non-custodial']}
|
||||
provider='cln'
|
||||
status={wallet !== undefined || undefined}
|
||||
/>
|
||||
)
|
||||
}
|
@ -2,29 +2,13 @@ import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||
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'
|
||||
import { LNDCard } from './lnd'
|
||||
import { CLNCard } from './cln'
|
||||
import { WALLETS } from '@/fragments/wallet'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import PageLoading from '@/components/page-loading'
|
||||
import { LNCCard } from './lnc'
|
||||
import { WALLETS as WALLETS_QUERY } from '@/fragments/wallet'
|
||||
import Link from 'next/link'
|
||||
import { Wallet as W } from '@/lib/constants'
|
||||
import { WALLET_DEFS } from '@/components/wallet'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps({ query: WALLETS, authRequired: true })
|
||||
export const getServerSideProps = getGetServerSideProps({ query: WALLETS_QUERY, authRequired: true })
|
||||
|
||||
export default function Wallet ({ ssrData }) {
|
||||
const { data } = useQuery(WALLETS)
|
||||
|
||||
if (!data && !ssrData) return <PageLoading />
|
||||
const { wallets } = data || ssrData
|
||||
const lnd = wallets.find(w => w.type === W.LND.type)
|
||||
const lnaddr = wallets.find(w => w.type === W.LnAddr.type)
|
||||
const cln = wallets.find(w => w.type === W.CLN.type)
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className='py-5 w-100'>
|
||||
@ -36,15 +20,9 @@ export default function Wallet ({ ssrData }) {
|
||||
</Link>
|
||||
</div>
|
||||
<div className={styles.walletGrid}>
|
||||
<LightningAddressWalletCard wallet={lnaddr} />
|
||||
<LNDCard wallet={lnd} />
|
||||
<CLNCard wallet={cln} />
|
||||
<LNbitsCard />
|
||||
<NWCCard />
|
||||
<LNCCard />
|
||||
<WalletCard title='coming soon' badges={['probably']} />
|
||||
<WalletCard title='coming soon' badges={['we hope']} />
|
||||
<WalletCard title='coming soon' badges={['tm']} />
|
||||
{WALLET_DEFS.map((def, i) =>
|
||||
<WalletCard key={i} name={def.name} {...def.card} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
@ -1,106 +0,0 @@
|
||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||
import { Form, Input } from '@/components/form'
|
||||
import { CenterLayout } from '@/components/layout'
|
||||
import { useMe } from '@/components/me'
|
||||
import { WalletButtonBar, WalletCard } from '@/components/wallet-card'
|
||||
import { useApolloClient, useMutation } from '@apollo/client'
|
||||
import { useToast } from '@/components/toast'
|
||||
import { lnAddrAutowithdrawSchema } from '@/lib/validate'
|
||||
import { useRouter } from 'next/router'
|
||||
import { AutowithdrawSettings, autowithdrawInitial } from '@/components/autowithdraw-shared'
|
||||
import { REMOVE_WALLET, UPSERT_WALLET_LNADDR, WALLET_BY_TYPE } from '@/fragments/wallet'
|
||||
import WalletLogs from '@/components/wallet-logs'
|
||||
import { Wallet } from '@/lib/constants'
|
||||
|
||||
const variables = { type: Wallet.LnAddr.type }
|
||||
export const getServerSideProps = getGetServerSideProps({ query: WALLET_BY_TYPE, variables, authRequired: true })
|
||||
|
||||
export default function LightningAddress ({ ssrData }) {
|
||||
const me = useMe()
|
||||
const toaster = useToast()
|
||||
const router = useRouter()
|
||||
const client = useApolloClient()
|
||||
const [upsertWalletLNAddr] = useMutation(UPSERT_WALLET_LNADDR, {
|
||||
refetchQueries: ['WalletLogs'],
|
||||
onError: (err) => {
|
||||
client.refetchQueries({ include: ['WalletLogs'] })
|
||||
throw err
|
||||
}
|
||||
})
|
||||
const [removeWallet] = useMutation(REMOVE_WALLET, {
|
||||
refetchQueries: ['WalletLogs'],
|
||||
onError: (err) => {
|
||||
client.refetchQueries({ include: ['WalletLogs'] })
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
const { walletByType: wallet } = ssrData || {}
|
||||
|
||||
return (
|
||||
<CenterLayout>
|
||||
<h2 className='pb-2'>lightning address</h2>
|
||||
<h6 className='text-muted text-center pb-3'>autowithdraw to a lightning address</h6>
|
||||
<Form
|
||||
initial={{
|
||||
address: wallet?.wallet?.address || '',
|
||||
...autowithdrawInitial({ me, priority: wallet?.priority })
|
||||
}}
|
||||
schema={lnAddrAutowithdrawSchema({ me })}
|
||||
onSubmit={async ({ address, ...settings }) => {
|
||||
try {
|
||||
await upsertWalletLNAddr({
|
||||
variables: {
|
||||
id: wallet?.id,
|
||||
address,
|
||||
settings: {
|
||||
...settings,
|
||||
autoWithdrawThreshold: Number(settings.autoWithdrawThreshold),
|
||||
autoWithdrawMaxFeePercent: Number(settings.autoWithdrawMaxFeePercent)
|
||||
}
|
||||
}
|
||||
})
|
||||
toaster.success('saved settings')
|
||||
router.push('/settings/wallets')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label='lightning address'
|
||||
name='address'
|
||||
autoComplete='off'
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<AutowithdrawSettings />
|
||||
<WalletButtonBar
|
||||
status={!!wallet} onDelete={async () => {
|
||||
try {
|
||||
await removeWallet({ variables: { id: wallet?.id } })
|
||||
toaster.success('saved settings')
|
||||
router.push('/settings/wallets')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form>
|
||||
<div className='mt-3 w-100'>
|
||||
<WalletLogs wallet={Wallet.LnAddr} embedded />
|
||||
</div>
|
||||
</CenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export function LightningAddressWalletCard ({ wallet }) {
|
||||
return (
|
||||
<WalletCard
|
||||
title='lightning address'
|
||||
badges={['receive only', 'non-custodialish']}
|
||||
provider='lightning-address'
|
||||
status={wallet !== undefined || undefined}
|
||||
/>
|
||||
)
|
||||
}
|
@ -1,99 +0,0 @@
|
||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||
import { Form, ClientInput, ClientCheckbox, PasswordInput } from '@/components/form'
|
||||
import { CenterLayout } from '@/components/layout'
|
||||
import { WalletButtonBar, WalletCard, isConfigured } 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'
|
||||
import { useWebLNConfigurator } from '@/components/webln'
|
||||
import WalletLogs from '@/components/wallet-logs'
|
||||
import { Wallet } from '@/lib/constants'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
|
||||
|
||||
export default function LNbits () {
|
||||
const { provider, enabledProviders, setProvider } = useWebLNConfigurator()
|
||||
const lnbits = useLNbits()
|
||||
const { name, url, adminKey, saveConfig, clearConfig, status } = lnbits
|
||||
const isDefault = provider?.name === name
|
||||
const configured = isConfigured(status)
|
||||
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 ({ isDefault, ...values }) => {
|
||||
try {
|
||||
await saveConfig(values)
|
||||
if (isDefault) setProvider(lnbits)
|
||||
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
|
||||
/>
|
||||
<PasswordInput
|
||||
initialValue={adminKey}
|
||||
label='admin key'
|
||||
name='adminKey'
|
||||
newPass
|
||||
required
|
||||
/>
|
||||
<ClientCheckbox
|
||||
disabled={!configured || isDefault || enabledProviders.length === 1}
|
||||
initialValue={isDefault}
|
||||
label='default payment method'
|
||||
name='isDefault'
|
||||
/>
|
||||
<WalletButtonBar
|
||||
status={status} onDelete={async () => {
|
||||
try {
|
||||
await clearConfig()
|
||||
toaster.success('saved settings')
|
||||
router.push('/settings/wallets')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toaster.danger('failed to detach: ' + err.message || err.toString?.())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form>
|
||||
<div className='mt-3 w-100'>
|
||||
<WalletLogs wallet={Wallet.LNbits} embedded />
|
||||
</div>
|
||||
</CenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export function LNbitsCard () {
|
||||
const { status } = useLNbits()
|
||||
return (
|
||||
<WalletCard
|
||||
title='LNbits'
|
||||
badges={['send only', 'non-custodialish']}
|
||||
provider='lnbits'
|
||||
status={status}
|
||||
/>
|
||||
)
|
||||
}
|
@ -1,122 +0,0 @@
|
||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||
import { WalletSecurityBanner } from '@/components/banners'
|
||||
import { ClientCheckbox, Form, PasswordInput } from '@/components/form'
|
||||
import Info from '@/components/info'
|
||||
import { CenterLayout } from '@/components/layout'
|
||||
import Text from '@/components/text'
|
||||
import { useToast } from '@/components/toast'
|
||||
import { WalletButtonBar, WalletCard, isConfigured } from '@/components/wallet-card'
|
||||
import WalletLogs from '@/components/wallet-logs'
|
||||
import { Status, useWebLNConfigurator } from '@/components/webln'
|
||||
import { XXX_DEFAULT_PASSWORD, useLNC } from '@/components/webln/lnc'
|
||||
import { lncSchema } from '@/lib/validate'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Wallet } from '@/lib/constants'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
|
||||
|
||||
export default function LNC () {
|
||||
const { provider, enabledProviders, setProvider } = useWebLNConfigurator()
|
||||
const toaster = useToast()
|
||||
const router = useRouter()
|
||||
const lnc = useLNC()
|
||||
const { status, clearConfig, saveConfig, config, name, unlock } = lnc
|
||||
const isDefault = provider?.name === name
|
||||
const unlocking = useRef(false)
|
||||
const configured = isConfigured(status)
|
||||
|
||||
useEffect(() => {
|
||||
if (!unlocking.current && status === Status.Locked) {
|
||||
unlocking.current = true
|
||||
unlock()
|
||||
}
|
||||
}, [status, unlock])
|
||||
|
||||
const defaultPassword = config?.password === XXX_DEFAULT_PASSWORD
|
||||
|
||||
return (
|
||||
<CenterLayout>
|
||||
<h2>Lightning Node Connect for LND</h2>
|
||||
<h6 className='text-muted text-center pb-3'>use Lightning Node Connect for LND payments</h6>
|
||||
<WalletSecurityBanner />
|
||||
<Form
|
||||
initial={{
|
||||
pairingPhrase: config?.pairingPhrase || '',
|
||||
password: (!config?.password || defaultPassword) ? '' : config.password
|
||||
}}
|
||||
schema={lncSchema}
|
||||
onSubmit={async ({ isDefault, ...values }) => {
|
||||
try {
|
||||
await saveConfig(values)
|
||||
if (isDefault) setProvider(lnc)
|
||||
toaster.success('saved settings')
|
||||
router.push('/settings/wallets')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toaster.danger('failed to attach: ' + err.message || err.toString?.())
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PasswordInput
|
||||
label={
|
||||
<div className='d-flex align-items-center'>pairing phrase
|
||||
<Info label='help'>
|
||||
<Text>
|
||||
{'We only need permissions for the uri `/lnrpc.Lightning/SendPaymentSync`\n\nCreate a budgeted account with narrow permissions:\n\n```$ litcli accounts create --balance <budget>```\n\n```$ litcli sessions add --type custom --label <your label> --account_id <account_id> --uri /lnrpc.Lightning/SendPaymentSync```\n\nGrab the `pairing_secret_mnemonic` from the output and paste it here.'}
|
||||
</Text>
|
||||
</Info>
|
||||
</div>
|
||||
}
|
||||
name='pairingPhrase'
|
||||
initialValue={config?.pairingPhrase}
|
||||
newPass={config?.pairingPhrase === undefined}
|
||||
readOnly={configured}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<PasswordInput
|
||||
label={<>password <small className='text-muted ms-2'>optional</small></>}
|
||||
name='password'
|
||||
initialValue={defaultPassword ? '' : config?.password}
|
||||
newPass={config?.password === undefined || defaultPassword}
|
||||
readOnly={configured}
|
||||
hint='encrypts your pairing phrase when stored locally'
|
||||
/>
|
||||
<ClientCheckbox
|
||||
disabled={!configured || isDefault || enabledProviders?.length === 1}
|
||||
initialValue={isDefault}
|
||||
label='default payment method'
|
||||
name='isDefault'
|
||||
/>
|
||||
<WalletButtonBar
|
||||
status={status} onDelete={async () => {
|
||||
try {
|
||||
await clearConfig()
|
||||
toaster.success('saved settings')
|
||||
router.push('/settings/wallets')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toaster.danger('failed to detach: ' + err.message || err.toString?.())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form>
|
||||
<div className='mt-3 w-100'>
|
||||
<WalletLogs wallet={Wallet.LNC} embedded />
|
||||
</div>
|
||||
</CenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export function LNCCard () {
|
||||
const { status } = useLNC()
|
||||
return (
|
||||
<WalletCard
|
||||
title='LNC'
|
||||
badges={['send only', 'non-custodial', 'budgetable']}
|
||||
provider='lnc'
|
||||
status={status}
|
||||
/>
|
||||
)
|
||||
}
|
@ -1,137 +0,0 @@
|
||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||
import { Form, Input } from '@/components/form'
|
||||
import { CenterLayout } from '@/components/layout'
|
||||
import { useMe } from '@/components/me'
|
||||
import { WalletButtonBar, WalletCard } from '@/components/wallet-card'
|
||||
import { useApolloClient, useMutation } from '@apollo/client'
|
||||
import { useToast } from '@/components/toast'
|
||||
import { LNDAutowithdrawSchema } from '@/lib/validate'
|
||||
import { useRouter } from 'next/router'
|
||||
import { AutowithdrawSettings, autowithdrawInitial } from '@/components/autowithdraw-shared'
|
||||
import { REMOVE_WALLET, UPSERT_WALLET_LND, WALLET_BY_TYPE } from '@/fragments/wallet'
|
||||
import Info from '@/components/info'
|
||||
import Text from '@/components/text'
|
||||
import WalletLogs from '@/components/wallet-logs'
|
||||
import { Wallet } from '@/lib/constants'
|
||||
|
||||
const variables = { type: Wallet.LND.type }
|
||||
export const getServerSideProps = getGetServerSideProps({ query: WALLET_BY_TYPE, variables, authRequired: true })
|
||||
|
||||
export default function LND ({ ssrData }) {
|
||||
const me = useMe()
|
||||
const toaster = useToast()
|
||||
const router = useRouter()
|
||||
const client = useApolloClient()
|
||||
const [upsertWalletLND] = useMutation(UPSERT_WALLET_LND, {
|
||||
refetchQueries: ['WalletLogs'],
|
||||
onError: (err) => {
|
||||
client.refetchQueries({ include: ['WalletLogs'] })
|
||||
throw err
|
||||
}
|
||||
})
|
||||
const [removeWallet] = useMutation(REMOVE_WALLET, {
|
||||
refetchQueries: ['WalletLogs'],
|
||||
onError: (err) => {
|
||||
client.refetchQueries({ include: ['WalletLogs'] })
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
const { walletByType: wallet } = ssrData || {}
|
||||
|
||||
return (
|
||||
<CenterLayout>
|
||||
<h2 className='pb-2'>LND</h2>
|
||||
<h6 className='text-muted text-center pb-3'>autowithdraw to your Lightning Labs node</h6>
|
||||
<Form
|
||||
initial={{
|
||||
socket: wallet?.wallet?.socket || '',
|
||||
macaroon: wallet?.wallet?.macaroon || '',
|
||||
cert: wallet?.wallet?.cert || '',
|
||||
...autowithdrawInitial({ me, priority: wallet?.priority })
|
||||
}}
|
||||
schema={LNDAutowithdrawSchema({ me })}
|
||||
onSubmit={async ({ socket, cert, macaroon, ...settings }) => {
|
||||
try {
|
||||
await upsertWalletLND({
|
||||
variables: {
|
||||
id: wallet?.id,
|
||||
socket,
|
||||
macaroon,
|
||||
cert,
|
||||
settings: {
|
||||
...settings,
|
||||
autoWithdrawThreshold: Number(settings.autoWithdrawThreshold),
|
||||
autoWithdrawMaxFeePercent: Number(settings.autoWithdrawMaxFeePercent)
|
||||
}
|
||||
}
|
||||
})
|
||||
toaster.success('saved settings')
|
||||
router.push('/settings/wallets')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label='grpc host and port'
|
||||
name='socket'
|
||||
hint='tor or clearnet'
|
||||
placeholder='55.5.555.55:10001'
|
||||
clear
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label={
|
||||
<div className='d-flex align-items-center'>invoice macaroon
|
||||
<Info label='privacy tip'>
|
||||
<Text>
|
||||
{'We accept a prebaked ***invoice.macaroon*** for your convenience. To gain better privacy, generate a new macaroon as follows:\n\n```lncli bakemacaroon invoices:write invoices:read```'}
|
||||
</Text>
|
||||
</Info>
|
||||
</div>
|
||||
}
|
||||
name='macaroon'
|
||||
clear
|
||||
hint='hex or base64 encoded'
|
||||
placeholder='AgEDbG5kAlgDChCn7YgfWX7uTkQQgXZ2uahNEgEwGhYKB2FkZHJlc3MSBHJlYWQSBXdyaXRlGhcKCGludm9pY2VzEgRyZWFkEgV3cml0ZRoPCgdvbmNoYWluEgRyZWFkAAAGIJkMBrrDV0npU90JV0TGNJPrqUD8m2QYoTDjolaL6eBs'
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label={<>cert <small className='text-muted ms-2'>optional if from <a href='https://en.wikipedia.org/wiki/Certificate_authority' target='_blank' rel='noreferrer'>CA</a> (e.g. voltage)</small></>}
|
||||
name='cert'
|
||||
clear
|
||||
hint='hex or base64 encoded'
|
||||
placeholder='LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K'
|
||||
/>
|
||||
<AutowithdrawSettings />
|
||||
<WalletButtonBar
|
||||
status={!!wallet} onDelete={async () => {
|
||||
try {
|
||||
await removeWallet({ variables: { id: wallet?.id } })
|
||||
toaster.success('saved settings')
|
||||
router.push('/settings/wallets')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form>
|
||||
<div className='mt-3 w-100'>
|
||||
<WalletLogs wallet={Wallet.LND} embedded />
|
||||
</div>
|
||||
</CenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export function LNDCard ({ wallet }) {
|
||||
return (
|
||||
<WalletCard
|
||||
title='LND'
|
||||
badges={['receive only', 'non-custodial']}
|
||||
provider='lnd'
|
||||
status={wallet !== undefined || undefined}
|
||||
/>
|
||||
)
|
||||
}
|
@ -1,92 +0,0 @@
|
||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||
import { Form, ClientCheckbox, PasswordInput } from '@/components/form'
|
||||
import { CenterLayout } from '@/components/layout'
|
||||
import { WalletButtonBar, WalletCard, isConfigured } 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'
|
||||
import { useWebLNConfigurator } from '@/components/webln'
|
||||
import WalletLogs from '@/components/wallet-logs'
|
||||
import { Wallet } from '@/lib/constants'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
|
||||
|
||||
export default function NWC () {
|
||||
const { provider, enabledProviders, setProvider } = useWebLNConfigurator()
|
||||
const nwc = useNWC()
|
||||
const { name, nwcUrl, saveConfig, clearConfig, status } = nwc
|
||||
const isDefault = provider?.name === name
|
||||
const configured = isConfigured(status)
|
||||
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 ({ isDefault, ...values }) => {
|
||||
try {
|
||||
await saveConfig(values)
|
||||
if (isDefault) setProvider(nwc)
|
||||
toaster.success('saved settings')
|
||||
router.push('/settings/wallets')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toaster.danger('failed to attach: ' + err.message || err.toString?.())
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PasswordInput
|
||||
initialValue={nwcUrl}
|
||||
label='connection'
|
||||
name='nwcUrl'
|
||||
newPass
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<ClientCheckbox
|
||||
disabled={!configured || isDefault || enabledProviders.length === 1}
|
||||
initialValue={isDefault}
|
||||
label='default payment method'
|
||||
name='isDefault'
|
||||
/>
|
||||
<WalletButtonBar
|
||||
status={status} onDelete={async () => {
|
||||
try {
|
||||
await clearConfig()
|
||||
toaster.success('saved settings')
|
||||
router.push('/settings/wallets')
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toaster.danger('failed to detach: ' + err.message || err.toString?.())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form>
|
||||
<div className='mt-3 w-100'>
|
||||
<WalletLogs wallet={Wallet.NWC} embedded />
|
||||
</div>
|
||||
</CenterLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export function NWCCard () {
|
||||
const { status } = useNWC()
|
||||
return (
|
||||
<WalletCard
|
||||
title='NWC'
|
||||
badges={['send only', 'non-custodialish', 'budgetable']}
|
||||
provider='nwc'
|
||||
status={status}
|
||||
/>
|
||||
)
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { CenterLayout } from '@/components/layout'
|
||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||
import WalletLogs from '@/components/wallet-logs'
|
||||
import { WalletLogs } from '@/components/wallet-logger'
|
||||
|
||||
export const getServerSideProps = getGetServerSideProps({ query: null })
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user