wip: Add LND autowithdrawals

* receiving wallets need to export 'server' object field
* don't print macaroon error stack
* fix missing wallet logs order update
* mark autowithdrawl settings as required
* fix server wallet logs deletion
* remove canPay and canReceive since it was confusing where it is available

TODO

* also use numeric priority for sending wallets to be consistent with how status for receiving wallets is determined
* define createInvoice function in wallet definition
* consistent wallet logs: sending wallets use 'wallet attached'+'wallet enabled/disabled' whereas receiving wallets use 'wallet created/updated'
* see FIXMEs
This commit is contained in:
ekzyis 2024-06-26 14:31:35 +02:00
parent ae0335d537
commit 4082a45618
6 changed files with 178 additions and 29 deletions

View File

@ -46,12 +46,14 @@ export function AutowithdrawSettings ({ priority }) {
}} }}
hint={isNumber(sendThreshold) ? `will attempt auto-withdraw when your balance exceeds ${sendThreshold * 11} sats` : undefined} hint={isNumber(sendThreshold) ? `will attempt auto-withdraw when your balance exceeds ${sendThreshold * 11} sats` : undefined}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>} append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
required
/> />
<Input <Input
label='max fee' label='max fee'
name='autoWithdrawMaxFeePercent' name='autoWithdrawMaxFeePercent'
hint='max fee as percent of withdrawal amount' hint='max fee as percent of withdrawal amount'
append={<InputGroup.Text>%</InputGroup.Text>} append={<InputGroup.Text>%</InputGroup.Text>}
required
/> />
</div> </div>
</div> </div>

View File

@ -5,7 +5,7 @@ import { Button } from 'react-bootstrap'
import { useToast } from './toast' import { useToast } from './toast'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { WALLET_LOGS } from '@/fragments/wallet' import { WALLET_LOGS } from '@/fragments/wallet'
import { getWalletByName } from './wallet' import { getServerWallet } from './wallet'
import { gql, useMutation, useQuery } from '@apollo/client' import { gql, useMutation, useQuery } from '@apollo/client'
import { useMe } from './me' import { useMe } from './me'
@ -128,12 +128,11 @@ export const WalletLoggerProvider = ({ children }) => {
.map(({ createdAt, wallet: walletType, ...log }) => { .map(({ createdAt, wallet: walletType, ...log }) => {
return { return {
ts: +new Date(createdAt), ts: +new Date(createdAt),
// TODO: use wallet defs wallet: getServerWallet(walletType).name,
// wallet: getWalletBy('type', walletType).logTag,
...log ...log
} }
}) })
return [...prevLogs, ...logs].sort((a, b) => a.ts - b.ts) return [...prevLogs, ...logs].sort((a, b) => b.ts - a.ts)
}) })
} }
}) })
@ -148,7 +147,7 @@ export const WalletLoggerProvider = ({ children }) => {
onCompleted: (_, { variables: { wallet: walletType } }) => { onCompleted: (_, { variables: { wallet: walletType } }) => {
setLogs((logs) => { setLogs((logs) => {
// TODO: use wallet defs // TODO: use wallet defs
return logs.filter(l => walletType ? l.wallet !== getWalletByName('type', walletType) : false) return logs.filter(l => walletType ? l.wallet !== getServerWallet(walletType).name : false)
}) })
} }
} }
@ -206,10 +205,10 @@ export const WalletLoggerProvider = ({ children }) => {
}, [saveLog]) }, [saveLog])
const deleteLogs = useCallback(async (wallet) => { const deleteLogs = useCallback(async (wallet) => {
if (!wallet || wallet.canReceive) { if (!wallet || wallet.server) {
await deleteServerWalletLogs({ variables: { wallet: wallet?.type } }) await deleteServerWalletLogs({ variables: { wallet: wallet?.server } })
} }
if (!wallet || wallet.canPay) { if (!wallet || wallet.sendPayment) {
const tx = idb.current.transaction(idbStoreName, 'readwrite') const tx = idb.current.transaction(idbStoreName, 'readwrite')
const objectStore = tx.objectStore(idbStoreName) const objectStore = tx.objectStore(idbStoreName)
const idx = objectStore.index('wallet_ts') const idx = objectStore.index('wallet_ts')
@ -244,6 +243,10 @@ export function useWalletLogger (wallet) {
console.error('cannot log: no wallet set') console.error('cannot log: no wallet set')
return return
} }
// don't store logs for receiving wallets on client since logs are stored on server
if (wallet.server) return
// TODO: // TODO:
// also send this to us if diagnostics was enabled, // also send this to us if diagnostics was enabled,
// very similar to how the service worker logger works. // very similar to how the service worker logger works.

View File

@ -8,9 +8,13 @@ import { bolt11Tags } from '@/lib/bolt11'
import * as lnbits from '@/components/wallet/lnbits' import * as lnbits from '@/components/wallet/lnbits'
import * as nwc from '@/components/wallet/nwc' import * as nwc from '@/components/wallet/nwc'
import * as lnc from '@/components/wallet/lnc' import * as lnc from '@/components/wallet/lnc'
import * as lnd from '@/components/wallet/lnd'
import { useApolloClient, useMutation, useQuery } from '@apollo/client'
import { REMOVE_WALLET, WALLET_BY_TYPE } from '@/fragments/wallet'
import { autowithdrawInitial } from '../autowithdraw-shared'
// wallet definitions // wallet definitions
export const WALLET_DEFS = [lnbits, nwc, lnc] export const WALLET_DEFS = [lnbits, nwc, lnc, lnd]
export const Status = { export const Status = {
Initialized: 'Initialized', Initialized: 'Initialized',
@ -22,11 +26,12 @@ export const Status = {
export function useWallet (name) { export function useWallet (name) {
const me = useMe() const me = useMe()
const wallet = name ? getWalletByName(name, me) : getEnabledWallet(me) const wallet = name ? getWalletByName(name) : getEnabledWallet(me)
const { logger } = useWalletLogger(wallet) const { logger } = useWalletLogger(wallet)
const storageKey = getStorageKey(wallet?.name, me)
const [config, saveConfig, clearConfig] = useLocalState(storageKey)
const [config, saveConfig, clearConfig] = useConfig(wallet)
// FIXME: This throws 'TypeError: Cannot read properties of undefined (reading 'length')' when I disable LNbits
const sendPayment = useCallback(async (bolt11) => { const sendPayment = useCallback(async (bolt11) => {
const hash = bolt11Tags(bolt11).payment_hash const hash = bolt11Tags(bolt11).payment_hash
logger.info('sending payment:', `payment_hash=${hash}`) logger.info('sending payment:', `payment_hash=${hash}`)
@ -53,9 +58,10 @@ export function useWallet (name) {
const save = useCallback(async (config) => { const save = useCallback(async (config) => {
try { try {
// validate should log custom INFO and OK message // validate should log custom INFO and OK message
// validate is optional since validation might happen during save on server
// TODO: add timeout // TODO: add timeout
await wallet.validate({ me, logger, ...config }) await wallet.validate?.({ me, logger, ...config })
saveConfig(config) await saveConfig(config)
logger.ok('wallet attached') logger.ok('wallet attached')
} catch (err) { } catch (err) {
const message = err.message || err.toString?.() const message = err.message || err.toString?.()
@ -85,17 +91,97 @@ export function useWallet (name) {
enable, enable,
disable, disable,
isConfigured: !!config, isConfigured: !!config,
status: config?.enabled ? Status.Enabled : Status.Initialized, status: config?.enabled || config?.priority ? Status.Enabled : Status.Initialized,
canPay: !!wallet?.sendPayment,
canReceive: !!wallet?.createInvoice,
logger logger
} }
} }
export function getWalletByName (name, me) { function useConfig (wallet) {
if (!wallet) return []
if (wallet.sendPayment) {
// FIXME: this throws 'Error: Should have a queue' when I enable LNbits
// probably because of conditional hooks?
return useLocalConfig(wallet)
}
if (wallet.server) {
return useServerConfig(wallet)
}
// TODO: if wallets can do both return a merged version that knows which field goes where
return []
}
function useLocalConfig (wallet) {
const me = useMe()
const storageKey = getStorageKey(wallet?.name, me)
return useLocalState(storageKey)
}
function useServerConfig (wallet) {
const client = useApolloClient()
const me = useMe()
const { walletType, mutation } = wallet.server
const { data } = useQuery(WALLET_BY_TYPE, { variables: { type: walletType } })
const [upsertWallet] = useMutation(mutation, {
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 walletId = data?.walletByType.id
const serverConfig = { id: walletId, priority: data?.walletByType.priority, ...data?.walletByType.wallet }
const autowithdrawSettings = autowithdrawInitial({ me, priority: serverConfig?.priority })
const config = { ...serverConfig, autowithdrawSettings }
const saveConfig = useCallback(async ({
autoWithdrawThreshold,
autoWithdrawMaxFeePercent,
priority,
...config
}) => {
await upsertWallet({
variables: {
id: walletId,
...config,
settings: {
autoWithdrawThreshold: Number(autoWithdrawThreshold),
autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent),
priority: !!priority
}
}
})
}, [upsertWallet, walletId])
const clearConfig = useCallback(async () => {
await removeWallet({ variables: { id: config?.id } })
}, [removeWallet, config?.id])
return [config, saveConfig, clearConfig]
}
export function getWalletByName (name) {
return WALLET_DEFS.find(def => def.name === name) return WALLET_DEFS.find(def => def.name === name)
} }
export function getServerWallet (type) {
return WALLET_DEFS.find(def => def.server?.walletType === type)
}
export function getEnabledWallet (me) { export function getEnabledWallet (me) {
// TODO: handle multiple enabled wallets // TODO: handle multiple enabled wallets
return WALLET_DEFS return WALLET_DEFS

48
components/wallet/lnd.js Normal file
View File

@ -0,0 +1,48 @@
import { UPSERT_WALLET_LND } from '@/fragments/wallet'
import { LNDAutowithdrawSchema } from '@/lib/validate'
export const name = 'lnd'
export const fields = [
{
name: 'socket',
label: 'grpc host and port',
type: 'text',
placeholder: '55.5.555.55:10001',
hint: 'tor or clearnet',
clear: true
},
{
name: 'macaroon',
label: 'invoice macaroon',
help: {
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```'
},
type: 'text',
placeholder: 'AgEDbG5kAlgDChCn7YgfWX7uTkQQgXZ2uahNEgEwGhYKB2FkZHJlc3MSBHJlYWQSBXdyaXRlGhcKCGludm9pY2VzEgRyZWFkEgV3cml0ZRoPCgdvbmNoYWluEgRyZWFkAAAGIJkMBrrDV0npU90JV0TGNJPrqUD8m2QYoTDjolaL6eBs',
hint: 'hex or base64 encoded',
clear: true
},
{
name: 'cert',
label: 'cert',
type: 'text',
placeholder: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K',
optional: <>optional if from <a href='https://en.wikipedia.org/wiki/Certificate_authority' target='_blank' rel='noreferrer'>CA</a> (e.g. voltage)</>,
clear: true
}
]
export const card = {
title: 'LND',
subtitle: 'autowithdraw to your Lightning Labs node',
badges: ['receive only', 'non-custodial']
}
export const schema = LNDAutowithdrawSchema
export const server = {
walletType: 'LND',
mutation: UPSERT_WALLET_LND
}

View File

@ -22,7 +22,7 @@ function macaroonOPs (macaroon) {
} }
} }
} catch (e) { } catch (e) {
console.error('macaroonOPs error:', e) console.error('macaroonOPs error:', e.message)
} }
return [] return []

View File

@ -9,6 +9,7 @@ import { useRouter } from 'next/router'
import { useWallet, Status } from '@/components/wallet' import { useWallet, Status } from '@/components/wallet'
import Info from '@/components/info' import Info from '@/components/info'
import Text from '@/components/text' import Text from '@/components/text'
import { AutowithdrawSettings } from '@/components/autowithdraw-shared'
export const getServerSideProps = getGetServerSideProps({ authRequired: true }) export const getServerSideProps = getGetServerSideProps({ authRequired: true })
@ -23,7 +24,7 @@ export default function WalletSettings () {
...acc, ...acc,
[field.name]: wallet.config?.[field.name] || '' [field.name]: wallet.config?.[field.name] || ''
} }
}, {}) }, wallet.server ? wallet.config.autowithdrawSettings : {})
return ( return (
<CenterLayout> <CenterLayout>
@ -50,12 +51,16 @@ export default function WalletSettings () {
}} }}
> >
<WalletFields wallet={wallet} /> <WalletFields wallet={wallet} />
<ClientCheckbox {wallet.server
disabled={false} ? <AutowithdrawSettings />
initialValue={wallet.status === Status.Enabled} : (
label='enabled' <ClientCheckbox
name='enabled' disabled={false}
/> initialValue={wallet.status === Status.Enabled}
label='enabled'
name='enabled'
/>
)}
<WalletButtonBar <WalletButtonBar
wallet={wallet} onDelete={async () => { wallet={wallet} onDelete={async () => {
try { try {
@ -86,12 +91,17 @@ function WalletFields ({ wallet: { config, fields } }) {
label: ( label: (
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>
{label} {label}
{/* help can be a string or object to customize the label */}
{help && ( {help && (
<Info label='help'> <Info label={help.label || 'help'}>
<Text>{help}</Text> <Text>{help.text || help}</Text>
</Info> </Info>
)} )}
{optional && <small className='text-muted ms-2'>optional</small>} {optional && (
<small className='text-muted ms-2'>
{typeof optional === 'boolean' ? 'optional' : optional}
</small>
)}
</div> </div>
), ),
required: !optional, required: !optional,