From ae73b0c19fd277e1dfd2bac289a869b1dc980cfb Mon Sep 17 00:00:00 2001 From: ekzyis Date: Mon, 12 Aug 2024 17:23:39 -0500 Subject: [PATCH] Support receiving via LNbits (#1278) * Support receiving with LNbits * Remove hardcoded LNbits url on server * Fix saveConfig ignoring save errors * saveConfig was meant to only ignore validation errors, not save errors * on server save errors, we redirected as if save was successful * this is now fixed with a promise chain * logging payments vs receivals was also moved to correct place * Fix enabled falsely disabled on SSR If a wallet was configured for payments but not for receivals and you refreshed the configuration form, enabled was disabled even though payments were enabled. This was the case since we don't know during SSR if it's enabled since this information is stored on the client. * Fix missing 'receivals disabled' log message * Move 'wallet detached for payments' log message * Fix stale walletId during detach If page was reloaded, walletId in clearConfig was stale since callback dependency was missing. * Add missing callback dependencies for saveConfig * Verify that invoiceKey != adminKey * Verify LNbits keys are hex-encoded * Fix local config polluted with server data * Fix creation of duplicate wallets * Remove unused dependency * Fix missing error message in logs * Fix setPriority * Rename: localConfig -> clientConfig * Add description to LNbits autowithdrawals * Rename: receivals -> receives * Use try/catch instead of promise chain in saveConfig * add connect label to lnbits for no url found for lnbits * Fix adminKey not saved * Remove hardcoded LNbits url on server again * Add LNbits ATTACH.md * Delete old docs to attach LNbits with polar * Add missing callback dependencies * Set editable: false * Only set readOnly if field is configured --------- Co-authored-by: keyan Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> --- api/resolvers/wallet.js | 39 ++-- api/typeDefs/wallet.js | 24 ++- components/autowithdraw-shared.js | 7 +- components/wallet-logger.js | 3 - docker-compose.yml | 2 + docs/attach-lnbits.md | 81 -------- fragments/wallet.js | 8 + lib/validate.js | 41 +++- pages/settings/wallets/[wallet].js | 10 +- .../20240729195320_lnbits_recv/migration.sql | 24 +++ prisma/schema.prisma | 12 ++ wallets/index.js | 193 +++++++++++++----- wallets/lnbits/ATTACH.md | 27 +++ wallets/lnbits/client.js | 8 +- wallets/lnbits/index.js | 19 +- wallets/lnbits/server.js | 30 +++ wallets/server.js | 3 +- 17 files changed, 342 insertions(+), 189 deletions(-) delete mode 100644 docs/attach-lnbits.md create mode 100644 prisma/migrations/20240729195320_lnbits_recv/migration.sql create mode 100644 wallets/lnbits/ATTACH.md create mode 100644 wallets/lnbits/server.js diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 5a5c9600..c817cb65 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -5,7 +5,7 @@ import serialize from './serial' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { SELECT, itemQueryWithMeta } from './item' import { msatsToSats, msatsToSatsDecimal } from '@/lib/format' -import { amountSchema, ssValidate, withdrawlSchema, lnAddrSchema, formikValidate } from '@/lib/validate' +import { amountSchema, ssValidate, withdrawlSchema, lnAddrSchema, walletValidate } from '@/lib/validate' import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT } from '@/lib/constants' import { datePivot } from '@/lib/time' import assertGofacYourself from './ofac' @@ -19,20 +19,14 @@ import { lnAddrOptions } from '@/lib/lnurl' function injectResolvers (resolvers) { console.group('injected GraphQL resolvers:') for (const w of walletDefs) { - const { fieldValidation, walletType, walletField, testConnectServer } = w - const resolverName = generateResolverName(walletField) + const resolverName = generateResolverName(w.walletField) console.log(resolverName) - // check if wallet uses the form-level validation built into Formik or a Yup schema - const validateArgs = typeof fieldValidation === 'function' - ? { formikValidate: fieldValidation } - : { schema: fieldValidation } - resolvers.Mutation[resolverName] = async (parent, { settings, ...data }, { me, models }) => { + await walletValidate(w, { ...data, ...settings }) return await upsertWallet({ - ...validateArgs, - wallet: { field: walletField, type: walletType }, - testConnectServer: (data) => testConnectServer(data, { me, models }) + wallet: { field: w.walletField, type: w.walletType }, + testConnectServer: (data) => w.testConnectServer(data, { me, models }) }, { settings, data }, { me, models }) } } @@ -353,7 +347,7 @@ const resolvers = { }, WalletDetails: { __resolveType (wallet) { - return wallet.address ? 'WalletLNAddr' : wallet.macaroon ? 'WalletLND' : 'WalletCLN' + return wallet.address ? 'WalletLNAddr' : wallet.macaroon ? 'WalletLND' : wallet.rune ? 'WalletCLN' : 'WalletLNbits' } }, Mutation: { @@ -462,7 +456,8 @@ const resolvers = { await models.$transaction([ models.wallet.delete({ where: { userId: me.id, id: Number(id) } }), - models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet detached' } }) + models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'INFO', message: 'receives disabled' } }), + models.walletLog.create({ data: { userId: me.id, wallet: wallet.type, level: 'SUCCESS', message: 'wallet detached for receives' } }) ]) return true @@ -557,26 +552,20 @@ export const addWalletLog = async ({ wallet, level, message }, { me, models }) = } async function upsertWallet ( - { schema, formikValidate: validate, wallet, testConnectServer }, { settings, data }, { me, models }) { + { wallet, testConnectServer }, { settings, data }, { me, models }) { if (!me) { throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) } assertApiKeyNotPermitted({ me }) - if (schema) { - await ssValidate(schema, { ...data, ...settings }, { me, models }) - } - if (validate) { - await formikValidate(validate, { ...data, ...settings }) - } - if (testConnectServer) { try { await testConnectServer(data) } catch (err) { console.error(err) - const message = err.message || err.toString?.() - await addWalletLog({ wallet, level: 'ERROR', message: 'failed to attach: ' + message }, { me, models }) + const message = 'failed to create test invoice: ' + (err.message || err.toString?.()) + await addWalletLog({ wallet, level: 'ERROR', message }, { me, models }) + await addWalletLog({ wallet, level: 'INFO', message: 'receives disabled' }, { me, models }) throw new GraphQLError(message, { extensions: { code: 'BAD_INPUT' } }) } } @@ -632,7 +621,7 @@ async function upsertWallet ( userId: me.id, wallet: wallet.type, level: 'SUCCESS', - message: id ? 'wallet updated' : 'wallet attached' + message: id ? 'receive details updated' : 'wallet attached for receives' } }), models.walletLog.create({ @@ -640,7 +629,7 @@ async function upsertWallet ( userId: me.id, wallet: wallet.type, level: enabled ? 'SUCCESS' : 'INFO', - message: enabled ? 'wallet enabled' : 'wallet disabled' + message: enabled ? 'receives enabled' : 'receives disabled' } }) ) diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index b8e9935a..c37f35c2 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -2,19 +2,22 @@ import { gql } from 'graphql-tag' import { generateResolverName } from '@/lib/wallet' import walletDefs from 'wallets/server' +import { isServerField } from 'wallets' function injectTypeDefs (typeDefs) { console.group('injected GraphQL type defs:') const injected = walletDefs.map( (w) => { let args = 'id: ID, ' - args += w.fields.map(f => { - let arg = `${f.name}: String` - if (!f.optional) { - arg += '!' - } - return arg - }).join(', ') + args += w.fields + .filter(isServerField) + .map(f => { + let arg = `${f.name}: String` + if (!f.optional) { + arg += '!' + } + return arg + }).join(', ') args += ', settings: AutowithdrawSettings!' const resolverName = generateResolverName(w.walletField) const typeDef = `${resolverName}(${args}): Boolean` @@ -74,7 +77,12 @@ const typeDefs = ` cert: String } - union WalletDetails = WalletLNAddr | WalletLND | WalletCLN + type WalletLNbits { + url: String! + invoiceKey: String! + } + + union WalletDetails = WalletLNAddr | WalletLND | WalletCLN | WalletLNbits input AutowithdrawSettings { autoWithdrawThreshold: Int! diff --git a/components/autowithdraw-shared.js b/components/autowithdraw-shared.js index e5f28244..ac0db019 100644 --- a/components/autowithdraw-shared.js +++ b/components/autowithdraw-shared.js @@ -25,10 +25,15 @@ export function AutowithdrawSettings ({ wallet }) { setSendThreshold(Math.max(Math.floor(threshold / 10), 1)) }, [autoWithdrawThreshold]) + const [mounted, setMounted] = useState(false) + useEffect(() => { + setMounted(true) + }, []) + return ( <> !value || HEX_REGEX.test(value) + }) +}) + const titleValidator = string().required('required').trim().max( MAX_TITLE_LENGTH, ({ max, value }) => `-${Math.abs(max - value.length)} characters remaining` @@ -621,7 +637,7 @@ export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) => return accum }, {}))) -export const lnbitsSchema = object({ +export const lnbitsSchema = object().shape({ url: process.env.NODE_ENV === 'development' ? string() .or([string().matches(/^(http:\/\/)?localhost:\d+$/), string().url()], 'invalid url') @@ -643,8 +659,25 @@ export const lnbitsSchema = object({ } return true }), - adminKey: string().length(32).required('required') -}) + adminKey: string().length(32).hex() + .when(['invoiceKey'], ([invoiceKey], schema) => { + if (!invoiceKey) return schema.required('required if invoice key not set') + return schema.test({ + test: adminKey => adminKey !== invoiceKey, + message: 'admin key cannot be the same as invoice key' + }) + }), + invoiceKey: string().length(32).hex() + .when(['adminKey'], ([adminKey], schema) => { + if (!adminKey) return schema.required('required if admin key not set') + return schema.test({ + test: invoiceKey => adminKey !== invoiceKey, + message: 'invoice key cannot be the same as admin key' + }) + }) + // need to set order to avoid cyclic dependencies in Yup schema + // see https://github.com/jquense/yup/issues/176#issuecomment-367352042 +}, ['adminKey', 'invoiceKey']) export const nwcSchema = object({ nwcUrl: string() diff --git a/pages/settings/wallets/[wallet].js b/pages/settings/wallets/[wallet].js index 89e3652a..c4121ffc 100644 --- a/pages/settings/wallets/[wallet].js +++ b/pages/settings/wallets/[wallet].js @@ -57,15 +57,11 @@ export default function WalletSettings () { await wallet.save(values) - if (values.enabled) 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) + toaster.danger(err.message || err.toString?.()) } }} > @@ -106,12 +102,12 @@ export default function WalletSettings () { function WalletFields ({ wallet: { config, fields, isConfigured } }) { return fields - .map(({ name, label, type, help, optional, editable, ...props }, i) => { + .map(({ name, label, type, help, optional, editable, clientOnly, serverOnly, ...props }, i) => { const rawProps = { ...props, name, initialValue: config?.[name], - readOnly: isConfigured && editable === false, + readOnly: isConfigured && editable === false && !!config?.[name], groupClassName: props.hidden ? 'd-none' : undefined, label: label ? ( diff --git a/prisma/migrations/20240729195320_lnbits_recv/migration.sql b/prisma/migrations/20240729195320_lnbits_recv/migration.sql new file mode 100644 index 00000000..c795f371 --- /dev/null +++ b/prisma/migrations/20240729195320_lnbits_recv/migration.sql @@ -0,0 +1,24 @@ +-- AlterEnum +ALTER TYPE "WalletType" ADD VALUE 'LNBITS'; + +-- CreateTable +CREATE TABLE "WalletLNbits" ( + "int" SERIAL NOT NULL, + "walletId" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "url" TEXT NOT NULL, + "invoiceKey" TEXT NOT NULL, + + CONSTRAINT "WalletLNbits_pkey" PRIMARY KEY ("int") +); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLNbits_walletId_key" ON "WalletLNbits"("walletId"); + +-- AddForeignKey +ALTER TABLE "WalletLNbits" ADD CONSTRAINT "WalletLNbits_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE TRIGGER wallet_lnbits_as_jsonb +AFTER INSERT OR UPDATE ON "WalletLNbits" +FOR EACH ROW EXECUTE PROCEDURE wallet_wallet_type_as_jsonb(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6a80a66b..8f5b52b9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -167,6 +167,7 @@ enum WalletType { LIGHTNING_ADDRESS LND CLN + LNBITS } model Wallet { @@ -190,6 +191,7 @@ model Wallet { walletLightningAddress WalletLightningAddress? walletLND WalletLND? walletCLN WalletCLN? + walletLNbits WalletLNbits? withdrawals Withdrawl[] @@index([userId]) @@ -238,6 +240,16 @@ model WalletCLN { cert String? } +model WalletLNbits { + int Int @id @default(autoincrement()) + walletId Int @unique + wallet Wallet @relation(fields: [walletId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + url String + invoiceKey String +} + model Mute { muterId Int mutedId Int diff --git a/wallets/index.js b/wallets/index.js index c449ca54..57332666 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -1,6 +1,6 @@ import { useCallback } from 'react' import { useMe } from '@/components/me' -import useLocalConfig from '@/components/use-local-state' +import useClientConfig from '@/components/use-local-state' import { useWalletLogger } from '@/components/wallet-logger' import { SSR } from '@/lib/constants' import { bolt11Tags } from '@/lib/bolt11' @@ -12,6 +12,7 @@ import { autowithdrawInitial } from '@/components/autowithdraw-shared' import { useShowModal } from '@/components/modal' import { useToast } from '../components/toast' import { generateResolverName } from '@/lib/wallet' +import { walletValidate } from '@/lib/validate' export const Status = { Initialized: 'Initialized', @@ -32,6 +33,22 @@ export function useWallet (name) { const hasConfig = wallet?.fields.length > 0 const _isConfigured = isConfigured({ ...wallet, config }) + const enablePayments = useCallback(() => { + enableWallet(name, me) + logger.ok('payments enabled') + }, [name, me, logger]) + + const disablePayments = useCallback(() => { + disableWallet(name, me) + logger.info('payments disabled') + }, [name, me, logger]) + + if (wallet) { + wallet.isConfigured = _isConfigured + wallet.enablePayments = enablePayments + wallet.disablePayments = disablePayments + } + const status = config?.enabled ? Status.Enabled : Status.Initialized const enabled = status === Status.Enabled const priority = config?.priority @@ -49,53 +66,40 @@ export function useWallet (name) { } }, [me, wallet, config, logger, status]) - const enable = useCallback(() => { - enableWallet(name, me) - logger.ok('wallet enabled') - }, [name, me, logger]) - - const disable = useCallback(() => { - disableWallet(name, me) - logger.info('wallet disabled') - }, [name, me, logger]) - const setPriority = useCallback(async (priority) => { if (_isConfigured && priority !== config.priority) { try { - await saveConfig({ ...config, priority }) + await saveConfig({ ...config, priority }, { logger }) } catch (err) { toaster.danger(`failed to change priority of ${wallet.name} wallet: ${err.message}`) } } - }, [wallet, config, logger, toaster]) + }, [wallet, config, toaster]) const save = useCallback(async (newConfig) => { + // testConnectClient should log custom INFO and OK message + // testConnectClient is optional since validation might happen during save on server + // TODO: add timeout + let validConfig try { - // testConnectClient should log custom INFO and OK message - // testConnectClient is optional since validation might happen during save on server - // TODO: add timeout - const validConfig = await wallet.testConnectClient?.(newConfig, { me, logger }) - await saveConfig(validConfig ?? newConfig) - logger.ok(_isConfigured ? 'wallet updated' : 'wallet attached') + validConfig = await wallet.testConnectClient?.(newConfig, { me, logger }) } catch (err) { - const message = err.message || err.toString?.() - logger.error('failed to attach: ' + message) + logger.error(err.message) throw err } - }, [_isConfigured, saveConfig, me, logger]) + await saveConfig(validConfig ?? newConfig, { logger }) + }, [saveConfig, me, logger]) // delete is a reserved keyword const delete_ = useCallback(async () => { try { - await clearConfig() - logger.ok('wallet detached') - disable() + await clearConfig({ logger }) } catch (err) { const message = err.message || err.toString?.() logger.error(message) throw err } - }, [clearConfig, logger, disable]) + }, [clearConfig, logger, disablePayments]) if (!wallet) return null @@ -106,11 +110,8 @@ export function useWallet (name) { save, delete: delete_, deleteLogs, - enable, - disable, setPriority, hasConfig, - isConfigured: _isConfigured, status, enabled, priority, @@ -118,34 +119,111 @@ export function useWallet (name) { } } +function extractConfig (fields, config, client) { + return Object.entries(config).reduce((acc, [key, value]) => { + const field = fields.find(({ name }) => name === key) + + // filter server config which isn't specified as wallet fields + if (client && (key.startsWith('autoWithdraw') || key === 'id')) return acc + + // field might not exist because config.enabled doesn't map to a wallet field + if (!field || (client ? isClientField(field) : isServerField(field))) { + return { + ...acc, + [key]: value + } + } else { + return acc + } + }, {}) +} + +export function isServerField (f) { + return f.serverOnly || !f.clientOnly +} + +export function isClientField (f) { + return f.clientOnly || !f.serverOnly +} + +function extractClientConfig (fields, config) { + return extractConfig(fields, config, true) +} + +function extractServerConfig (fields, config) { + return extractConfig(fields, config, false) +} + function useConfig (wallet) { const me = useMe() const storageKey = getStorageKey(wallet?.name, me) - const [localConfig, setLocalConfig, clearLocalConfig] = useLocalConfig(storageKey) + const [clientConfig, setClientConfig, clearClientConfig] = useClientConfig(storageKey) const [serverConfig, setServerConfig, clearServerConfig] = useServerConfig(wallet) - const hasLocalConfig = !!wallet?.sendPayment + const hasClientConfig = !!wallet?.sendPayment const hasServerConfig = !!wallet?.walletType - const config = { - // only include config if it makes sense for this wallet - // since server config always returns default values for autowithdraw settings - // which might be confusing to have for wallets that don't support autowithdraw - ...(hasLocalConfig ? localConfig : {}), - ...(hasServerConfig ? serverConfig : {}) + let config = {} + if (hasClientConfig) config = clientConfig + if (hasServerConfig) { + const { enabled } = config || {} + config = { + ...config, + ...serverConfig + } + // wallet is enabled if enabled is set in client or server config + config.enabled ||= enabled } - const saveConfig = useCallback(async (config) => { - if (hasLocalConfig) setLocalConfig(config) - if (hasServerConfig) await setServerConfig(config) - }, [wallet]) + const saveConfig = useCallback(async (newConfig, { logger }) => { + // NOTE: + // verifying the client/server configuration before saving it + // prevents unsetting just one configuration if both are set. + // This means there is no way of unsetting just one configuration + // since 'detach' detaches both. + // Not optimal UX but the trade-off is saving invalid configurations + // and maybe it's not that big of an issue. + if (hasClientConfig) { + const newClientConfig = extractClientConfig(wallet.fields, newConfig) - const clearConfig = useCallback(async () => { - if (hasLocalConfig) clearLocalConfig() + let valid = true + try { + await walletValidate(wallet, newClientConfig) + } catch { + valid = false + } + + if (valid) { + setClientConfig(newClientConfig) + logger.ok(wallet.isConfigured ? 'payment details updated' : 'wallet attached for payments') + if (newConfig.enabled) wallet.enablePayments() + else wallet.disablePayments() + } + } + if (hasServerConfig) { + const newServerConfig = extractServerConfig(wallet.fields, newConfig) + + let valid = true + try { + await walletValidate(wallet, newServerConfig) + } catch { + valid = false + } + + if (valid) await setServerConfig(newServerConfig) + } + }, [hasClientConfig, hasServerConfig, setClientConfig, setServerConfig, wallet]) + + const clearConfig = useCallback(async ({ logger }) => { + if (hasClientConfig) { + clearClientConfig() + wallet.disablePayments() + logger.ok('wallet detached for payments') + } if (hasServerConfig) await clearServerConfig() - }, [wallet]) + }, [hasClientConfig, hasServerConfig, clearClientConfig, clearServerConfig, wallet]) return [config, saveConfig, clearConfig] } @@ -174,6 +252,8 @@ function useServerConfig (wallet) { enabled: data?.walletByType?.enabled, ...data?.walletByType?.wallet } + delete serverConfig.__typename + const autowithdrawSettings = autowithdrawInitial({ me }) const config = { ...serverConfig, ...autowithdrawSettings } @@ -189,8 +269,8 @@ function useServerConfig (wallet) { return await client.mutate({ mutation, variables: { - id: walletId, ...config, + id: walletId, settings: { autoWithdrawThreshold: Number(autoWithdrawThreshold), autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent), @@ -206,6 +286,9 @@ function useServerConfig (wallet) { }, [client, walletId]) const clearConfig = useCallback(async () => { + // only remove wallet if there is a wallet to remove + if (!walletId) return + try { await client.mutate({ mutation: REMOVE_WALLET, @@ -224,17 +307,21 @@ function generateMutation (wallet) { const resolverName = generateResolverName(wallet.walletField) let headerArgs = '$id: ID, ' - headerArgs += wallet.fields.map(f => { - let arg = `$${f.name}: String` - if (!f.optional) { - arg += '!' - } - return arg - }).join(', ') + headerArgs += wallet.fields + .filter(isServerField) + .map(f => { + let arg = `$${f.name}: String` + if (!f.optional) { + arg += '!' + } + return arg + }).join(', ') headerArgs += ', $settings: AutowithdrawSettings!' let inputArgs = 'id: $id, ' - inputArgs += wallet.fields.map(f => `${f.name}: $${f.name}`).join(', ') + inputArgs += wallet.fields + .filter(isServerField) + .map(f => `${f.name}: $${f.name}`).join(', ') inputArgs += ', settings: $settings' return gql`mutation ${resolverName}(${headerArgs}) { diff --git a/wallets/lnbits/ATTACH.md b/wallets/lnbits/ATTACH.md new file mode 100644 index 00000000..b34a213b --- /dev/null +++ b/wallets/lnbits/ATTACH.md @@ -0,0 +1,27 @@ +For testing LNbits, you need to create a LNbits account first via the web interface. + +By default, you can access it at `localhost:5001` (see `LNBITS_WEB_PORT` in .env.development). + +After you created a wallet, you should find the invoice and admin key under `Node URL, API keys and API docs`. + +> [!IMPORTANT] +> +> Since your browser is running on your host machine but the server is running inside a docker container, the server will not be able to reach LNbits with `localhost:5001` to create invoices. This makes it hard to test send+receive at the same time. +> +> For now, you need to patch the `_createInvoice` function in wallets/lnbits/server.js to always use `lnbits:5000` as the URL: +> +> ```diff +> diff --git a/wallets/lnbits/server.js b/wallets/lnbits/server.js +> index 39949775..e3605c45 100644 +> --- a/wallets/lnbits/server.js +> +++ b/wallets/lnbits/server.js +> @@ -11,6 +11,7 @@ async function _createInvoice ({ url, invoiceKey, amount, expiry }, { me }) { +> const memo = me.hideInvoiceDesc ? undefined : 'autowithdraw to LNbits from SN' +> const body = JSON.stringify({ amount, unit: 'sat', expiry, memo, out: false }) +> +> + url = 'http://lnbits:5000' +> const res = await fetch(url + path, { method: 'POST', headers, body }) +> if (!res.ok) { +> const errBody = await res.json() +> ``` +> diff --git a/wallets/lnbits/client.js b/wallets/lnbits/client.js index 38c74f66..8b88f79a 100644 --- a/wallets/lnbits/client.js +++ b/wallets/lnbits/client.js @@ -1,10 +1,10 @@ export * from 'wallets/lnbits' -export async function testConnectClient ({ url, adminKey }, { logger }) { +export async function testConnectClient ({ url, adminKey, invoiceKey }, { logger }) { logger.info('trying to fetch wallet') url = url.replace(/\/+$/, '') - await getWallet({ url, adminKey }) + await getWallet({ url, adminKey, invoiceKey }) logger.ok('wallet found') } @@ -23,13 +23,13 @@ export async function sendPayment (bolt11, { url, adminKey }) { return { preimage } } -async function getWallet ({ url, adminKey }) { +async function getWallet ({ url, adminKey, invoiceKey }) { 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) + headers.append('X-Api-Key', adminKey || invoiceKey) const res = await fetch(url + path, { method: 'GET', headers }) if (!res.ok) { diff --git a/wallets/lnbits/index.js b/wallets/lnbits/index.js index 7d228e33..3473f47e 100644 --- a/wallets/lnbits/index.js +++ b/wallets/lnbits/index.js @@ -8,17 +8,32 @@ export const fields = [ label: 'lnbits url', type: 'text' }, + { + name: 'invoiceKey', + label: 'invoice key', + type: 'password', + optional: 'for receiving', + serverOnly: true, + editable: false + }, { name: 'adminKey', label: 'admin key', - type: 'password' + type: 'password', + optional: 'for sending', + clientOnly: true, + editable: false } ] export const card = { title: 'LNbits', subtitle: 'use [LNbits](https://lnbits.com/) for payments', - badges: ['send only'] + badges: ['send & receive'] } export const fieldValidation = lnbitsSchema + +export const walletType = 'LNBITS' + +export const walletField = 'walletLNbits' diff --git a/wallets/lnbits/server.js b/wallets/lnbits/server.js new file mode 100644 index 00000000..39949775 --- /dev/null +++ b/wallets/lnbits/server.js @@ -0,0 +1,30 @@ +export * from 'wallets/lnbits' + +async function _createInvoice ({ url, invoiceKey, amount, expiry }, { me }) { + 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', invoiceKey) + + const memo = me.hideInvoiceDesc ? undefined : 'autowithdraw to LNbits from SN' + const body = JSON.stringify({ amount, unit: 'sat', expiry, memo, out: false }) + + 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.payment_request +} + +export async function testConnectServer ({ url, invoiceKey }, { me }) { + return await _createInvoice({ url, invoiceKey, amount: 1, expiry: 1 }, { me }) +} + +export async function createInvoice ({ amount, maxFee }, { url, invoiceKey }, { me }) { + return await _createInvoice({ url, invoiceKey, amount, expiry: 360 }, { me }) +} diff --git a/wallets/server.js b/wallets/server.js index b625824d..89dad61e 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -1,5 +1,6 @@ import * as lnd from 'wallets/lnd/server' import * as cln from 'wallets/cln/server' import * as lnAddr from 'wallets/lightning-address/server' +import * as lnbits from 'wallets/lnbits/server' -export default [lnd, cln, lnAddr] +export default [lnd, cln, lnAddr, lnbits]