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:
parent
ae0335d537
commit
4082a45618
|
@ -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>
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -22,7 +22,7 @@ function macaroonOPs (macaroon) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('macaroonOPs error:', e)
|
console.error('macaroonOPs error:', e.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in New Issue