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 <keyan.kousha+huumn@gmail.com>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
ekzyis 2024-08-12 17:23:39 -05:00 committed by GitHub
parent 68758b3443
commit ae73b0c19f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 342 additions and 189 deletions

View File

@ -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'
}
})
)

View File

@ -2,13 +2,16 @@ 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 => {
args += w.fields
.filter(isServerField)
.map(f => {
let arg = `${f.name}: String`
if (!f.optional) {
arg += '!'
@ -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!

View File

@ -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 (
<>
<Checkbox
disabled={!wallet.isConfigured}
disabled={mounted && !wallet.isConfigured}
label='enabled'
id='enabled'
name='enabled'

View File

@ -243,9 +243,6 @@ export function useWalletLogger (wallet) {
return
}
// don't store logs for receiving wallets on client since logs are stored on server
if (wallet.walletType) return
// TODO:
// also send this to us if diagnostics was enabled,
// very similar to how the service worker logger works.

View File

@ -541,6 +541,8 @@ services:
- LND_GRPC_MACAROON=/app/.lnd/regtest/admin.macaroon
volumes:
- ./docker/lnd/stacker:/app/.lnd
labels:
CONNECT: "localhost:${LNBITS_WEB_PORT}"
cpu_shares: "${CPU_SHARES_LOW}"
volumes:
db:

View File

@ -1,81 +0,0 @@
# attach lnbits
To test sending from an attached wallet, it's easiest to use [lnbits](https://lnbits.com/) hooked up to a [local lnd node](./local-lnd.md) in your regtest network.
This will attempt to walk you through setting up lnbits with docker and connecting it to your local lnd node.
🚨 this a dev guide. do not use this guide for real funds 🚨
From [this guide](https://docs.lnbits.org/guide/installation.html#option-3-docker):
## 1. pre-configuration
Create a directory for lnbits, get the sample environment file, and create a shared data directory for lnbits to use:
```bash
mkdir lnbits
cd lnbits
wget https://raw.githubusercontent.com/lnbits/lnbits/main/.env.example -O .env
mkdir data
```
## 2. configure
To configure lnbits to use a [local lnd node](./local-lnd.md) in your regtest network, go to [polar](https://lightningpolar.com/) and click on the LND node you want to use as a funding source. Then click on `Connect`.
In the `Connect` tab, click the `File paths` tab and copy the `TLS cert` and `Admin macaroon` files to the `data` directory you created earlier.
```bash
cp /path/to/tls.cert /path/to/admin.macaroon data/
```
Then, open the `.env` file you created and override the following values:
```bash
LNBITS_ADMIN_UI=true
LNBITS_BACKEND_WALLET_CLASS=LndWallet
LND_GRPC_ENDPOINT=host.docker.internal
LND_GRPC_PORT=${Port from the polar connect page}
LND_GRPC_CERT=data/tls.cert
LND_GRPC_MACAROON=data/admin.macaroon
```
## 2. Install and run lnbits
Pull the latest image:
```bash
docker pull lnbitsdocker/lnbits-legend
docker run --detach --publish 5001:5000 --name lnbits --volume ${PWD}/.env:/app/.env --volume ${PWD}/data/:/app/data lnbitsdocker/lnbits-legend
```
Note: we make lnbits available on the host's port 5001 here (on Mac, 5000 is used by AirPlay), but you can change that to whatever you want.
## 3. Accessing the admin wallet
By enabling the [Admin UI](https://docs.lnbits.org/guide/admin_ui.html), lnbits creates a so called super_user. Get this super_user id by running:
```bash
cat data/.super_user
```
Open your browser and go to `http://localhost:5001/wallet?usr=${super_user id from above}`. LNBits will redirect you to a default wallet we will use called `LNBits wallet`.
## 4. Fund the wallet
To fund `LNBits wallet`, click the `+` next the wallet balance. Enter the number of sats you want to credit the wallet and hit enter.
## 5. Attach the wallet to stackernews
Open up your local stackernews, go to `http://localhost:3000/settings/wallets` and click on `attach` in the `lnbits` card.
In the form, fill in `lnbits url` with `http://localhost:5001`.
Back in lnbits click on `API Docs` in the right pane. Copy the Admin key and paste it into the `admin key` field in the form.
Click `attach` and you should be good to go.
## Debugging
- you can view lnbits logs with `docker logs lnbits` or in `data/logs/` in the `data` directory you created earlier
- with the [Admin UI](https://docs.lnbits.org/guide/admin_ui.html), you can modify LNBits in the GUI by clicking `Server` in left pane

View File

@ -129,6 +129,10 @@ export const WALLET = gql`
rune
cert
}
... on WalletLNbits {
url
invoiceKey
}
}
}
}
@ -157,6 +161,10 @@ export const WALLET_BY_TYPE = gql`
rune
cert
}
... on WalletLNbits {
url
invoiceKey
}
}
}
}

View File

@ -6,7 +6,7 @@ import {
} from './constants'
import { SUPPORTED_CURRENCIES } from './currency'
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr'
import { msatsToSats, numWithUnits, abbrNum, ensureB64, B64_URL_REGEX } from './format'
import { msatsToSats, numWithUnits, abbrNum, ensureB64, B64_URL_REGEX, HEX_REGEX } from './format'
import * as usersFragments from '@/fragments/users'
import * as subsFragments from '@/fragments/subs'
import { isInvoicableMacaroon, isInvoiceMacaroon } from './macaroon'
@ -41,6 +41,14 @@ export async function formikValidate (validate, data) {
}
}
export async function walletValidate (wallet, data) {
if (typeof wallet.fieldValidation === 'function') {
return await formikValidate(wallet.fieldValidation, data)
} else {
await ssValidate(wallet.fieldValidation, data)
}
}
addMethod(string, 'or', function (schemas, msg) {
return this.test({
name: 'or',
@ -142,6 +150,14 @@ addMethod(string, 'wss', function (msg) {
})
})
addMethod(string, 'hex', function (msg) {
return this.test({
name: 'hex',
message: msg || 'invalid hex encoding',
test: (value) => !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()

View File

@ -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
? (

View File

@ -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();

View File

@ -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

View File

@ -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) => {
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')
let validConfig
try {
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,7 +307,9 @@ function generateMutation (wallet) {
const resolverName = generateResolverName(wallet.walletField)
let headerArgs = '$id: ID, '
headerArgs += wallet.fields.map(f => {
headerArgs += wallet.fields
.filter(isServerField)
.map(f => {
let arg = `$${f.name}: String`
if (!f.optional) {
arg += '!'
@ -234,7 +319,9 @@ function generateMutation (wallet) {
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}) {

27
wallets/lnbits/ATTACH.md Normal file
View File

@ -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()
> ```
>

View File

@ -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) {

View File

@ -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'

30
wallets/lnbits/server.js Normal file
View File

@ -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 })
}

View File

@ -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]