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}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
required
/>
<Input
label='max fee'
name='autoWithdrawMaxFeePercent'
hint='max fee as percent of withdrawal amount'
append={<InputGroup.Text>%</InputGroup.Text>}
required
/>
</div>
</div>

View File

@ -5,7 +5,7 @@ import { Button } from 'react-bootstrap'
import { useToast } from './toast'
import { useShowModal } from './modal'
import { WALLET_LOGS } from '@/fragments/wallet'
import { getWalletByName } from './wallet'
import { getServerWallet } from './wallet'
import { gql, useMutation, useQuery } from '@apollo/client'
import { useMe } from './me'
@ -128,12 +128,11 @@ export const WalletLoggerProvider = ({ children }) => {
.map(({ createdAt, wallet: walletType, ...log }) => {
return {
ts: +new Date(createdAt),
// TODO: use wallet defs
// wallet: getWalletBy('type', walletType).logTag,
wallet: getServerWallet(walletType).name,
...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 } }) => {
setLogs((logs) => {
// 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])
const deleteLogs = useCallback(async (wallet) => {
if (!wallet || wallet.canReceive) {
await deleteServerWalletLogs({ variables: { wallet: wallet?.type } })
if (!wallet || wallet.server) {
await deleteServerWalletLogs({ variables: { wallet: wallet?.server } })
}
if (!wallet || wallet.canPay) {
if (!wallet || wallet.sendPayment) {
const tx = idb.current.transaction(idbStoreName, 'readwrite')
const objectStore = tx.objectStore(idbStoreName)
const idx = objectStore.index('wallet_ts')
@ -244,6 +243,10 @@ export function useWalletLogger (wallet) {
console.error('cannot log: no wallet set')
return
}
// don't store logs for receiving wallets on client since logs are stored on server
if (wallet.server) return
// TODO:
// also send this to us if diagnostics was enabled,
// 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 nwc from '@/components/wallet/nwc'
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
export const WALLET_DEFS = [lnbits, nwc, lnc]
export const WALLET_DEFS = [lnbits, nwc, lnc, lnd]
export const Status = {
Initialized: 'Initialized',
@ -22,11 +26,12 @@ export const Status = {
export function useWallet (name) {
const me = useMe()
const wallet = name ? getWalletByName(name, me) : getEnabledWallet(me)
const wallet = name ? getWalletByName(name) : getEnabledWallet(me)
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 hash = bolt11Tags(bolt11).payment_hash
logger.info('sending payment:', `payment_hash=${hash}`)
@ -53,9 +58,10 @@ export function useWallet (name) {
const save = useCallback(async (config) => {
try {
// validate should log custom INFO and OK message
// validate is optional since validation might happen during save on server
// TODO: add timeout
await wallet.validate({ me, logger, ...config })
saveConfig(config)
await wallet.validate?.({ me, logger, ...config })
await saveConfig(config)
logger.ok('wallet attached')
} catch (err) {
const message = err.message || err.toString?.()
@ -85,17 +91,97 @@ export function useWallet (name) {
enable,
disable,
isConfigured: !!config,
status: config?.enabled ? Status.Enabled : Status.Initialized,
canPay: !!wallet?.sendPayment,
canReceive: !!wallet?.createInvoice,
status: config?.enabled || config?.priority ? Status.Enabled : Status.Initialized,
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)
}
export function getServerWallet (type) {
return WALLET_DEFS.find(def => def.server?.walletType === type)
}
export function getEnabledWallet (me) {
// TODO: handle multiple enabled wallets
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) {
console.error('macaroonOPs error:', e)
console.error('macaroonOPs error:', e.message)
}
return []

View File

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