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:
parent
68758b3443
commit
ae73b0c19f
|
@ -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'
|
||||
}
|
||||
})
|
||||
)
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
? (
|
||||
|
|
|
@ -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();
|
|
@ -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
|
||||
|
|
193
wallets/index.js
193
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}) {
|
||||
|
|
|
@ -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()
|
||||
> ```
|
||||
>
|
|
@ -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) {
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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 })
|
||||
}
|
|
@ -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]
|
||||
|
|
Loading…
Reference in New Issue