From ec4e1b5da7725a0450734cde0899c80b9e15bcc7 Mon Sep 17 00:00:00 2001
From: Keyan <34140557+huumn@users.noreply.github.com>
Date: Tue, 13 Feb 2024 13:17:56 -0600
Subject: [PATCH] LND autowithdraw (#806)
* wip
* wip
* improved validatation, test connection before save, code reuse
* worker send to lnd
* autowithdraw priority
---
api/resolvers/user.js | 32 +-
api/resolvers/wallet.js | 169 +++++-
api/typeDefs/user.js | 2 -
api/typeDefs/wallet.js | 32 +
components/autowithdraw-shared.js | 62 ++
fragments/users.js | 13 +-
fragments/wallet.js | 75 +++
lib/macaroon-id.js | 546 ++++++++++++++++++
lib/macaroon-id.proto | 14 +
lib/macaroon.js | 206 +++++++
lib/validate.js | 98 +++-
package-lock.json | 7 +
package.json | 2 +
pages/settings/wallets/index.js | 18 +-
pages/settings/wallets/lightning-address.js | 82 +--
pages/settings/wallets/lnd.js | 108 ++++
.../migration.sql | 131 +++++
prisma/schema.prisma | 49 ++
worker/autowithdraw.js | 126 ++++
worker/index.js | 3 +-
worker/wallet.js | 47 +-
21 files changed, 1663 insertions(+), 159 deletions(-)
create mode 100644 components/autowithdraw-shared.js
create mode 100644 lib/macaroon-id.js
create mode 100644 lib/macaroon-id.proto
create mode 100644 lib/macaroon.js
create mode 100644 pages/settings/wallets/lnd.js
create mode 100644 prisma/migrations/20240209013150_attach_wallets/migration.sql
create mode 100644 worker/autowithdraw.js
diff --git a/api/resolvers/user.js b/api/resolvers/user.js
index 03462e3b..24b5bbf7 100644
--- a/api/resolvers/user.js
+++ b/api/resolvers/user.js
@@ -3,7 +3,7 @@ import { join, resolve } from 'path'
import { GraphQLError } from 'graphql'
import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor'
import { msatsToSats } from '../../lib/format'
-import { bioSchema, emailSchema, lnAddrAutowithdrawSchema, settingsSchema, ssValidate, userSchema } from '../../lib/validate'
+import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '../../lib/validate'
import { getItem, updateItem, filterClause, createItem, whereClause, muteClause } from './item'
import { ANON_USER_ID, DELETE_USER_ID, RESERVED_MAX_USER_ID } from '../../lib/constants'
import { viewGroup } from './growth'
@@ -426,36 +426,6 @@ export default {
throw error
}
},
- setAutoWithdraw: async (parent, data, { me, models }) => {
- if (!me) {
- throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
- }
-
- await ssValidate(lnAddrAutowithdrawSchema, data, { me, models })
-
- await models.user.update({
- where: { id: me.id },
- data
- })
-
- return true
- },
- removeAutoWithdraw: async (parent, data, { me, models }) => {
- if (!me) {
- throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
- }
-
- await models.user.update({
- where: { id: me.id },
- data: {
- lnAddr: null,
- autoWithdrawThreshold: null,
- autoWithdrawMaxFeePercent: null
- }
- })
-
- return true
- },
setSettings: async (parent, { settings: { nostrRelays, ...data } }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js
index 7f381387..c2f79bc6 100644
--- a/api/resolvers/wallet.js
+++ b/api/resolvers/wallet.js
@@ -1,4 +1,4 @@
-import { getIdentity, createHodlInvoice, createInvoice, decodePaymentRequest, payViaPaymentRequest, cancelHodlInvoice, getInvoice as getInvoiceFromLnd, getNode } from 'ln-service'
+import { getIdentity, createHodlInvoice, createInvoice, decodePaymentRequest, payViaPaymentRequest, cancelHodlInvoice, getInvoice as getInvoiceFromLnd, getNode, authenticatedLndGrpc } from 'ln-service'
import { GraphQLError } from 'graphql'
import crypto from 'crypto'
import serialize from './serial'
@@ -7,10 +7,11 @@ import lnpr from 'bolt11'
import { SELECT } from './item'
import { lnAddrOptions } from '../../lib/lnurl'
import { msatsToSats, msatsToSatsDecimal } from '../../lib/format'
-import { amountSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '../../lib/validate'
+import { LNDAutowithdrawSchema, amountSchema, lnAddrAutowithdrawSchema, lnAddrSchema, ssValidate, withdrawlSchema } from '../../lib/validate'
import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, ANON_USER_ID, BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT } from '../../lib/constants'
import { datePivot } from '../../lib/time'
import assertGofacYourself from './ofac'
+import { HEX_REGEX } from '../../lib/macaroon'
export async function getInvoice (parent, { id }, { me, models, lnd }) {
const inv = await models.invoice.findUnique({
@@ -61,6 +62,42 @@ export function createHmac (hash) {
export default {
Query: {
invoice: getInvoice,
+ wallet: async (parent, { id }, { me, models }) => {
+ if (!me) {
+ throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
+ }
+
+ return await models.wallet.findUnique({
+ where: {
+ userId: me.id,
+ id: Number(id)
+ }
+ })
+ },
+ walletByType: async (parent, { type }, { me, models }) => {
+ if (!me) {
+ throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
+ }
+
+ const wallet = await models.wallet.findFirst({
+ where: {
+ userId: me.id,
+ type
+ }
+ })
+ return wallet
+ },
+ wallets: async (parent, args, { me, models }) => {
+ if (!me) {
+ throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
+ }
+
+ return await models.wallet.findMany({
+ where: {
+ userId: me.id
+ }
+ })
+ },
withdrawl: async (parent, { id }, { me, models, lnd }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
@@ -266,7 +303,11 @@ export default {
}
}
},
-
+ WalletDetails: {
+ __resolveType (wallet) {
+ return wallet.address ? 'WalletLNAddr' : 'WalletLND'
+ }
+ },
Mutation: {
createInvoice: async (parent, { amount, hodlInvoice = false, expireSecs = 3600 }, { me, models, lnd, headers }) => {
await ssValidate(amountSchema, { amount })
@@ -295,8 +336,6 @@ export default {
expires_at: expiresAt
})
- console.log('invoice', balanceLimit)
-
const [inv] = await serialize(models,
models.$queryRaw`SELECT * FROM create_invoice(${invoice.id}, ${hodlInvoice ? invoice.secret : null}::TEXT, ${invoice.request},
${expiresAt}::timestamp, ${amount * 1000}, ${user.id}::INTEGER, ${description}, NULL, NULL,
@@ -348,6 +387,52 @@ export default {
data: { bolt11: null, hash: null }
})
return { id }
+ },
+ upsertWalletLND: async (parent, { settings, ...data }, { me, models }) => {
+ // store hex inputs as base64
+ if (HEX_REGEX.test(data.macaroon)) {
+ data.macaroon = Buffer.from(data.macaroon, 'hex').toString('base64')
+ }
+ if (HEX_REGEX.test(data.cert)) {
+ data.cert = Buffer.from(data.cert, 'hex').toString('base64')
+ }
+
+ return await upsertWallet(
+ {
+ schema: LNDAutowithdrawSchema,
+ walletName: 'walletLND',
+ walletType: 'LND',
+ testConnect: async ({ cert, macaroon, socket }) => {
+ const { lnd } = await authenticatedLndGrpc({
+ cert,
+ macaroon,
+ socket
+ })
+ return await getIdentity({ lnd })
+ }
+ },
+ { settings, data }, { me, models })
+ },
+ upsertWalletLNAddr: async (parent, { settings, ...data }, { me, models }) => {
+ return await upsertWallet(
+ {
+ schema: lnAddrAutowithdrawSchema,
+ walletName: 'walletLightningAddress',
+ walletType: 'LIGHTNING_ADDRESS',
+ testConnect: async ({ address }) => {
+ return await lnAddrOptions(address)
+ }
+ },
+ { settings, data }, { me, models })
+ },
+ removeWallet: async (parent, { id }, { me, models }) => {
+ if (!me) {
+ throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
+ }
+
+ await models.wallet.delete({ where: { userId: me.id, id: Number(id) } })
+
+ return true
}
},
@@ -379,6 +464,80 @@ export default {
}
}
+async function upsertWallet (
+ { schema, walletName, walletType, testConnect }, { settings, data }, { me, models }) {
+ if (!me) {
+ throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } })
+ }
+
+ await ssValidate(schema, { ...data, ...settings }, { me, models })
+
+ if (testConnect) {
+ try {
+ await testConnect(data)
+ } catch (error) {
+ console.error(error)
+ throw new GraphQLError('failed to connect to wallet', { extensions: { code: 'BAD_INPUT' } })
+ }
+ }
+
+ const { id, ...walletData } = data
+ const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, priority } = settings
+
+ const txs = [
+ models.user.update({
+ where: { id: me.id },
+ data: {
+ autoWithdrawMaxFeePercent,
+ autoWithdrawThreshold
+ }
+ })
+ ]
+
+ if (priority) {
+ txs.push(
+ models.wallet.updateMany({
+ where: {
+ userId: me.id
+ },
+ data: {
+ priority: 0
+ }
+ }))
+ }
+
+ if (id) {
+ txs.push(
+ models.wallet.update({
+ where: { id: Number(id), userId: me.id },
+ data: {
+ priority: priority ? 1 : 0,
+ [walletName]: {
+ update: {
+ where: { walletId: Number(id) },
+ data: walletData
+ }
+ }
+ }
+ }))
+ } else {
+ txs.push(
+ models.wallet.create({
+ data: {
+ priority: Number(priority),
+ userId: me.id,
+ type: walletType,
+ [walletName]: {
+ create: walletData
+ }
+ }
+ }))
+ }
+
+ await models.$transaction(txs)
+ return true
+}
+
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, autoWithdraw = false }) {
await ssValidate(withdrawlSchema, { invoice, maxFee })
await assertGofacYourself({ models, headers })
diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js
index 61b826e5..6c41e9d7 100644
--- a/api/typeDefs/user.js
+++ b/api/typeDefs/user.js
@@ -31,8 +31,6 @@ export default gql`
subscribeUserPosts(id: ID): User
subscribeUserComments(id: ID): User
toggleMute(id: ID): User
- setAutoWithdraw(lnAddr: String!, autoWithdrawThreshold: Int!, autoWithdrawMaxFeePercent: Float!): Boolean
- removeAutoWithdraw: Boolean
}
type User {
diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js
index d170060d..c2cca6ae 100644
--- a/api/typeDefs/wallet.js
+++ b/api/typeDefs/wallet.js
@@ -7,6 +7,9 @@ export default gql`
numBolt11s: Int!
connectAddress: String!
walletHistory(cursor: String, inc: String): History
+ wallets: [Wallet!]!
+ wallet(id: ID!): Wallet
+ walletByType(type: String!): Wallet
}
extend type Mutation {
@@ -15,6 +18,35 @@ export default gql`
sendToLnAddr(addr: String!, amount: Int!, maxFee: Int!, comment: String, identifier: Boolean, name: String, email: String): Withdrawl!
cancelInvoice(hash: String!, hmac: String!): Invoice!
dropBolt11(id: ID): Withdrawl
+ upsertWalletLND(id: ID, socket: String!, macaroon: String!, cert: String, settings: AutowithdrawSettings!): Boolean
+ upsertWalletLNAddr(id: ID, address: String!, settings: AutowithdrawSettings!): Boolean
+ removeWallet(id: ID!): Boolean
+ }
+
+ type Wallet {
+ id: ID!
+ createdAt: Date!
+ type: String!
+ priority: Boolean!
+ wallet: WalletDetails!
+ }
+
+ type WalletLNAddr {
+ address: String!
+ }
+
+ type WalletLND {
+ socket: String!
+ macaroon: String!
+ cert: String
+ }
+
+ union WalletDetails = WalletLNAddr | WalletLND
+
+ input AutowithdrawSettings {
+ autoWithdrawThreshold: Int!
+ autoWithdrawMaxFeePercent: Float!
+ priority: Boolean!
}
type Invoice {
diff --git a/components/autowithdraw-shared.js b/components/autowithdraw-shared.js
new file mode 100644
index 00000000..b826f829
--- /dev/null
+++ b/components/autowithdraw-shared.js
@@ -0,0 +1,62 @@
+import { InputGroup } from 'react-bootstrap'
+import { Checkbox, Input } from './form'
+import { useMe } from './me'
+import { useEffect, useState } from 'react'
+import { isNumber } from 'mathjs'
+import { numWithUnits } from '../lib/format'
+
+function autoWithdrawThreshold ({ me }) {
+ return isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000
+}
+
+export function autowithdrawInitial ({ me, priority = false }) {
+ return {
+ priority,
+ autoWithdrawThreshold: autoWithdrawThreshold({ me }),
+ autoWithdrawMaxFeePercent: isNumber(me?.privates?.autoWithdrawMaxFeePercent) ? me?.privates?.autoWithdrawMaxFeePercent : 1
+ }
+}
+
+export function AutowithdrawSettings ({ priority }) {
+ const me = useMe()
+ const threshold = autoWithdrawThreshold({ me })
+
+ const [sendThreshold, setSendThreshold] = useState(Math.max(Math.floor(threshold / 10), 1))
+
+ useEffect(() => {
+ setSendThreshold(Math.max(Math.floor(threshold / 10), 1))
+ }, [autoWithdrawThreshold])
+
+ return (
+ <>
+
+
+ >
+
+ )
+}
diff --git a/fragments/users.js b/fragments/users.js
index 7a1c8d47..2d12673b 100644
--- a/fragments/users.js
+++ b/fragments/users.js
@@ -111,17 +111,10 @@ mutation setSettings($settings: SettingsInput!) {
}
`
-export const SET_AUTOWITHDRAW =
+export const DELETE_WALLET =
gql`
-mutation setAutoWithdraw($lnAddr: String!, $autoWithdrawThreshold: Int!, $autoWithdrawMaxFeePercent: Float!) {
- setAutoWithdraw(lnAddr: $lnAddr, autoWithdrawThreshold: $autoWithdrawThreshold, autoWithdrawMaxFeePercent: $autoWithdrawMaxFeePercent)
-}
-`
-
-export const REMOVE_AUTOWITHDRAW =
-gql`
-mutation removeAutoWithdraw {
- removeAutoWithdraw
+mutation removeWallet {
+ removeWallet
}
`
diff --git a/fragments/wallet.js b/fragments/wallet.js
index d530cc9c..f1a3c09e 100644
--- a/fragments/wallet.js
+++ b/fragments/wallet.js
@@ -74,3 +74,78 @@ export const SEND_TO_LNADDR = gql`
id
}
}`
+
+export const UPSERT_WALLET_LNADDR =
+gql`
+mutation upsertWalletLNAddr($id: ID, $address: String!, $settings: AutowithdrawSettings!) {
+ upsertWalletLNAddr(id: $id, address: $address, settings: $settings)
+}
+`
+
+export const UPSERT_WALLET_LND =
+gql`
+mutation upsertWalletLND($id: ID, $socket: String!, $macaroon: String!, $cert: String, $settings: AutowithdrawSettings!) {
+ upsertWalletLND(id: $id, socket: $socket, macaroon: $macaroon, cert: $cert, settings: $settings)
+}
+`
+
+export const REMOVE_WALLET =
+gql`
+mutation removeWallet($id: ID!) {
+ removeWallet(id: $id)
+}
+`
+
+export const WALLET = gql`
+ query Wallet($id: ID!) {
+ wallet(id: $id) {
+ id
+ createdAt
+ priority
+ type
+ wallet {
+ __typename
+ ... on WalletLNAddr {
+ address
+ }
+ ... on WalletLND {
+ socket
+ macaroon
+ cert
+ }
+ }
+ }
+ }
+`
+
+export const WALLET_BY_TYPE = gql`
+ query WalletByType($type: String!) {
+ walletByType(type: $type) {
+ id
+ createdAt
+ priority
+ type
+ wallet {
+ __typename
+ ... on WalletLNAddr {
+ address
+ }
+ ... on WalletLND {
+ socket
+ macaroon
+ cert
+ }
+ }
+ }
+ }
+`
+
+export const WALLETS = gql`
+ query Wallets {
+ wallets {
+ id
+ priority
+ type
+ }
+ }
+`
diff --git a/lib/macaroon-id.js b/lib/macaroon-id.js
new file mode 100644
index 00000000..266dbe8f
--- /dev/null
+++ b/lib/macaroon-id.js
@@ -0,0 +1,546 @@
+// GENERATED pbjs -t static-module -w es6 lib/macaroon-id.proto -l eslint-disable -o lib/macaroon-id.js
+/*eslint-disable*/
+// REPLACED AFTER GENERATION because tsx does not like the * import
+// import * as $protobuf from "protobufjs/minimal";
+import $protobuf from "protobufjs/minimal";
+
+// Common aliases
+const $Reader = $protobuf.Reader, $Writer = $protobuf.Writer, $util = $protobuf.util;
+
+// Exported root namespace
+const $root = $protobuf.roots["default"] || ($protobuf.roots["default"] = {});
+
+export const MacaroonId = $root.MacaroonId = (() => {
+
+ /**
+ * Properties of a MacaroonId.
+ * @exports IMacaroonId
+ * @interface IMacaroonId
+ * @property {Uint8Array|null} [nonce] MacaroonId nonce
+ * @property {Uint8Array|null} [storageId] MacaroonId storageId
+ * @property {Array.|null} [ops] MacaroonId ops
+ */
+
+ /**
+ * Constructs a new MacaroonId.
+ * @exports MacaroonId
+ * @classdesc Represents a MacaroonId.
+ * @implements IMacaroonId
+ * @constructor
+ * @param {IMacaroonId=} [properties] Properties to set
+ */
+ function MacaroonId(properties) {
+ this.ops = [];
+ if (properties)
+ for (let keys = Object.keys(properties), i = 0; i < keys.length; ++i)
+ if (properties[keys[i]] != null)
+ this[keys[i]] = properties[keys[i]];
+ }
+
+ /**
+ * MacaroonId nonce.
+ * @member {Uint8Array} nonce
+ * @memberof MacaroonId
+ * @instance
+ */
+ MacaroonId.prototype.nonce = $util.newBuffer([]);
+
+ /**
+ * MacaroonId storageId.
+ * @member {Uint8Array} storageId
+ * @memberof MacaroonId
+ * @instance
+ */
+ MacaroonId.prototype.storageId = $util.newBuffer([]);
+
+ /**
+ * MacaroonId ops.
+ * @member {Array.} ops
+ * @memberof MacaroonId
+ * @instance
+ */
+ MacaroonId.prototype.ops = $util.emptyArray;
+
+ /**
+ * Creates a new MacaroonId instance using the specified properties.
+ * @function create
+ * @memberof MacaroonId
+ * @static
+ * @param {IMacaroonId=} [properties] Properties to set
+ * @returns {MacaroonId} MacaroonId instance
+ */
+ MacaroonId.create = function create(properties) {
+ return new MacaroonId(properties);
+ };
+
+ /**
+ * Encodes the specified MacaroonId message. Does not implicitly {@link MacaroonId.verify|verify} messages.
+ * @function encode
+ * @memberof MacaroonId
+ * @static
+ * @param {IMacaroonId} message MacaroonId message or plain object to encode
+ * @param {$protobuf.Writer} [writer] Writer to encode to
+ * @returns {$protobuf.Writer} Writer
+ */
+ MacaroonId.encode = function encode(message, writer) {
+ if (!writer)
+ writer = $Writer.create();
+ if (message.nonce != null && Object.hasOwnProperty.call(message, "nonce"))
+ writer.uint32(/* id 1, wireType 2 =*/10).bytes(message.nonce);
+ if (message.storageId != null && Object.hasOwnProperty.call(message, "storageId"))
+ writer.uint32(/* id 2, wireType 2 =*/18).bytes(message.storageId);
+ if (message.ops != null && message.ops.length)
+ for (let i = 0; i < message.ops.length; ++i)
+ $root.Op.encode(message.ops[i], writer.uint32(/* id 3, wireType 2 =*/26).fork()).ldelim();
+ return writer;
+ };
+
+ /**
+ * Encodes the specified MacaroonId message, length delimited. Does not implicitly {@link MacaroonId.verify|verify} messages.
+ * @function encodeDelimited
+ * @memberof MacaroonId
+ * @static
+ * @param {IMacaroonId} message MacaroonId message or plain object to encode
+ * @param {$protobuf.Writer} [writer] Writer to encode to
+ * @returns {$protobuf.Writer} Writer
+ */
+ MacaroonId.encodeDelimited = function encodeDelimited(message, writer) {
+ return this.encode(message, writer).ldelim();
+ };
+
+ /**
+ * Decodes a MacaroonId message from the specified reader or buffer.
+ * @function decode
+ * @memberof MacaroonId
+ * @static
+ * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from
+ * @param {number} [length] Message length if known beforehand
+ * @returns {MacaroonId} MacaroonId
+ * @throws {Error} If the payload is not a reader or valid buffer
+ * @throws {$protobuf.util.ProtocolError} If required fields are missing
+ */
+ MacaroonId.decode = function decode(reader, length) {
+ if (!(reader instanceof $Reader))
+ reader = $Reader.create(reader);
+ let end = length === undefined ? reader.len : reader.pos + length, message = new $root.MacaroonId();
+ while (reader.pos < end) {
+ let tag = reader.uint32();
+ switch (tag >>> 3) {
+ case 1: {
+ message.nonce = reader.bytes();
+ break;
+ }
+ case 2: {
+ message.storageId = reader.bytes();
+ break;
+ }
+ case 3: {
+ if (!(message.ops && message.ops.length))
+ message.ops = [];
+ message.ops.push($root.Op.decode(reader, reader.uint32()));
+ break;
+ }
+ default:
+ reader.skipType(tag & 7);
+ break;
+ }
+ }
+ return message;
+ };
+
+ /**
+ * Decodes a MacaroonId message from the specified reader or buffer, length delimited.
+ * @function decodeDelimited
+ * @memberof MacaroonId
+ * @static
+ * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from
+ * @returns {MacaroonId} MacaroonId
+ * @throws {Error} If the payload is not a reader or valid buffer
+ * @throws {$protobuf.util.ProtocolError} If required fields are missing
+ */
+ MacaroonId.decodeDelimited = function decodeDelimited(reader) {
+ if (!(reader instanceof $Reader))
+ reader = new $Reader(reader);
+ return this.decode(reader, reader.uint32());
+ };
+
+ /**
+ * Verifies a MacaroonId message.
+ * @function verify
+ * @memberof MacaroonId
+ * @static
+ * @param {Object.} message Plain object to verify
+ * @returns {string|null} `null` if valid, otherwise the reason why it is not
+ */
+ MacaroonId.verify = function verify(message) {
+ if (typeof message !== "object" || message === null)
+ return "object expected";
+ if (message.nonce != null && message.hasOwnProperty("nonce"))
+ if (!(message.nonce && typeof message.nonce.length === "number" || $util.isString(message.nonce)))
+ return "nonce: buffer expected";
+ if (message.storageId != null && message.hasOwnProperty("storageId"))
+ if (!(message.storageId && typeof message.storageId.length === "number" || $util.isString(message.storageId)))
+ return "storageId: buffer expected";
+ if (message.ops != null && message.hasOwnProperty("ops")) {
+ if (!Array.isArray(message.ops))
+ return "ops: array expected";
+ for (let i = 0; i < message.ops.length; ++i) {
+ let error = $root.Op.verify(message.ops[i]);
+ if (error)
+ return "ops." + error;
+ }
+ }
+ return null;
+ };
+
+ /**
+ * Creates a MacaroonId message from a plain object. Also converts values to their respective internal types.
+ * @function fromObject
+ * @memberof MacaroonId
+ * @static
+ * @param {Object.} object Plain object
+ * @returns {MacaroonId} MacaroonId
+ */
+ MacaroonId.fromObject = function fromObject(object) {
+ if (object instanceof $root.MacaroonId)
+ return object;
+ let message = new $root.MacaroonId();
+ if (object.nonce != null)
+ if (typeof object.nonce === "string")
+ $util.base64.decode(object.nonce, message.nonce = $util.newBuffer($util.base64.length(object.nonce)), 0);
+ else if (object.nonce.length >= 0)
+ message.nonce = object.nonce;
+ if (object.storageId != null)
+ if (typeof object.storageId === "string")
+ $util.base64.decode(object.storageId, message.storageId = $util.newBuffer($util.base64.length(object.storageId)), 0);
+ else if (object.storageId.length >= 0)
+ message.storageId = object.storageId;
+ if (object.ops) {
+ if (!Array.isArray(object.ops))
+ throw TypeError(".MacaroonId.ops: array expected");
+ message.ops = [];
+ for (let i = 0; i < object.ops.length; ++i) {
+ if (typeof object.ops[i] !== "object")
+ throw TypeError(".MacaroonId.ops: object expected");
+ message.ops[i] = $root.Op.fromObject(object.ops[i]);
+ }
+ }
+ return message;
+ };
+
+ /**
+ * Creates a plain object from a MacaroonId message. Also converts values to other types if specified.
+ * @function toObject
+ * @memberof MacaroonId
+ * @static
+ * @param {MacaroonId} message MacaroonId
+ * @param {$protobuf.IConversionOptions} [options] Conversion options
+ * @returns {Object.} Plain object
+ */
+ MacaroonId.toObject = function toObject(message, options) {
+ if (!options)
+ options = {};
+ let object = {};
+ if (options.arrays || options.defaults)
+ object.ops = [];
+ if (options.defaults) {
+ if (options.bytes === String)
+ object.nonce = "";
+ else {
+ object.nonce = [];
+ if (options.bytes !== Array)
+ object.nonce = $util.newBuffer(object.nonce);
+ }
+ if (options.bytes === String)
+ object.storageId = "";
+ else {
+ object.storageId = [];
+ if (options.bytes !== Array)
+ object.storageId = $util.newBuffer(object.storageId);
+ }
+ }
+ if (message.nonce != null && message.hasOwnProperty("nonce"))
+ object.nonce = options.bytes === String ? $util.base64.encode(message.nonce, 0, message.nonce.length) : options.bytes === Array ? Array.prototype.slice.call(message.nonce) : message.nonce;
+ if (message.storageId != null && message.hasOwnProperty("storageId"))
+ object.storageId = options.bytes === String ? $util.base64.encode(message.storageId, 0, message.storageId.length) : options.bytes === Array ? Array.prototype.slice.call(message.storageId) : message.storageId;
+ if (message.ops && message.ops.length) {
+ object.ops = [];
+ for (let j = 0; j < message.ops.length; ++j)
+ object.ops[j] = $root.Op.toObject(message.ops[j], options);
+ }
+ return object;
+ };
+
+ /**
+ * Converts this MacaroonId to JSON.
+ * @function toJSON
+ * @memberof MacaroonId
+ * @instance
+ * @returns {Object.} JSON object
+ */
+ MacaroonId.prototype.toJSON = function toJSON() {
+ return this.constructor.toObject(this, $protobuf.util.toJSONOptions);
+ };
+
+ /**
+ * Gets the default type url for MacaroonId
+ * @function getTypeUrl
+ * @memberof MacaroonId
+ * @static
+ * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com")
+ * @returns {string} The default type url
+ */
+ MacaroonId.getTypeUrl = function getTypeUrl(typeUrlPrefix) {
+ if (typeUrlPrefix === undefined) {
+ typeUrlPrefix = "type.googleapis.com";
+ }
+ return typeUrlPrefix + "/MacaroonId";
+ };
+
+ return MacaroonId;
+})();
+
+export const Op = $root.Op = (() => {
+
+ /**
+ * Properties of an Op.
+ * @exports IOp
+ * @interface IOp
+ * @property {string|null} [entity] Op entity
+ * @property {Array.|null} [actions] Op actions
+ */
+
+ /**
+ * Constructs a new Op.
+ * @exports Op
+ * @classdesc Represents an Op.
+ * @implements IOp
+ * @constructor
+ * @param {IOp=} [properties] Properties to set
+ */
+ function Op(properties) {
+ this.actions = [];
+ if (properties)
+ for (let keys = Object.keys(properties), i = 0; i < keys.length; ++i)
+ if (properties[keys[i]] != null)
+ this[keys[i]] = properties[keys[i]];
+ }
+
+ /**
+ * Op entity.
+ * @member {string} entity
+ * @memberof Op
+ * @instance
+ */
+ Op.prototype.entity = "";
+
+ /**
+ * Op actions.
+ * @member {Array.} actions
+ * @memberof Op
+ * @instance
+ */
+ Op.prototype.actions = $util.emptyArray;
+
+ /**
+ * Creates a new Op instance using the specified properties.
+ * @function create
+ * @memberof Op
+ * @static
+ * @param {IOp=} [properties] Properties to set
+ * @returns {Op} Op instance
+ */
+ Op.create = function create(properties) {
+ return new Op(properties);
+ };
+
+ /**
+ * Encodes the specified Op message. Does not implicitly {@link Op.verify|verify} messages.
+ * @function encode
+ * @memberof Op
+ * @static
+ * @param {IOp} message Op message or plain object to encode
+ * @param {$protobuf.Writer} [writer] Writer to encode to
+ * @returns {$protobuf.Writer} Writer
+ */
+ Op.encode = function encode(message, writer) {
+ if (!writer)
+ writer = $Writer.create();
+ if (message.entity != null && Object.hasOwnProperty.call(message, "entity"))
+ writer.uint32(/* id 1, wireType 2 =*/10).string(message.entity);
+ if (message.actions != null && message.actions.length)
+ for (let i = 0; i < message.actions.length; ++i)
+ writer.uint32(/* id 2, wireType 2 =*/18).string(message.actions[i]);
+ return writer;
+ };
+
+ /**
+ * Encodes the specified Op message, length delimited. Does not implicitly {@link Op.verify|verify} messages.
+ * @function encodeDelimited
+ * @memberof Op
+ * @static
+ * @param {IOp} message Op message or plain object to encode
+ * @param {$protobuf.Writer} [writer] Writer to encode to
+ * @returns {$protobuf.Writer} Writer
+ */
+ Op.encodeDelimited = function encodeDelimited(message, writer) {
+ return this.encode(message, writer).ldelim();
+ };
+
+ /**
+ * Decodes an Op message from the specified reader or buffer.
+ * @function decode
+ * @memberof Op
+ * @static
+ * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from
+ * @param {number} [length] Message length if known beforehand
+ * @returns {Op} Op
+ * @throws {Error} If the payload is not a reader or valid buffer
+ * @throws {$protobuf.util.ProtocolError} If required fields are missing
+ */
+ Op.decode = function decode(reader, length) {
+ if (!(reader instanceof $Reader))
+ reader = $Reader.create(reader);
+ let end = length === undefined ? reader.len : reader.pos + length, message = new $root.Op();
+ while (reader.pos < end) {
+ let tag = reader.uint32();
+ switch (tag >>> 3) {
+ case 1: {
+ message.entity = reader.string();
+ break;
+ }
+ case 2: {
+ if (!(message.actions && message.actions.length))
+ message.actions = [];
+ message.actions.push(reader.string());
+ break;
+ }
+ default:
+ reader.skipType(tag & 7);
+ break;
+ }
+ }
+ return message;
+ };
+
+ /**
+ * Decodes an Op message from the specified reader or buffer, length delimited.
+ * @function decodeDelimited
+ * @memberof Op
+ * @static
+ * @param {$protobuf.Reader|Uint8Array} reader Reader or buffer to decode from
+ * @returns {Op} Op
+ * @throws {Error} If the payload is not a reader or valid buffer
+ * @throws {$protobuf.util.ProtocolError} If required fields are missing
+ */
+ Op.decodeDelimited = function decodeDelimited(reader) {
+ if (!(reader instanceof $Reader))
+ reader = new $Reader(reader);
+ return this.decode(reader, reader.uint32());
+ };
+
+ /**
+ * Verifies an Op message.
+ * @function verify
+ * @memberof Op
+ * @static
+ * @param {Object.} message Plain object to verify
+ * @returns {string|null} `null` if valid, otherwise the reason why it is not
+ */
+ Op.verify = function verify(message) {
+ if (typeof message !== "object" || message === null)
+ return "object expected";
+ if (message.entity != null && message.hasOwnProperty("entity"))
+ if (!$util.isString(message.entity))
+ return "entity: string expected";
+ if (message.actions != null && message.hasOwnProperty("actions")) {
+ if (!Array.isArray(message.actions))
+ return "actions: array expected";
+ for (let i = 0; i < message.actions.length; ++i)
+ if (!$util.isString(message.actions[i]))
+ return "actions: string[] expected";
+ }
+ return null;
+ };
+
+ /**
+ * Creates an Op message from a plain object. Also converts values to their respective internal types.
+ * @function fromObject
+ * @memberof Op
+ * @static
+ * @param {Object.} object Plain object
+ * @returns {Op} Op
+ */
+ Op.fromObject = function fromObject(object) {
+ if (object instanceof $root.Op)
+ return object;
+ let message = new $root.Op();
+ if (object.entity != null)
+ message.entity = String(object.entity);
+ if (object.actions) {
+ if (!Array.isArray(object.actions))
+ throw TypeError(".Op.actions: array expected");
+ message.actions = [];
+ for (let i = 0; i < object.actions.length; ++i)
+ message.actions[i] = String(object.actions[i]);
+ }
+ return message;
+ };
+
+ /**
+ * Creates a plain object from an Op message. Also converts values to other types if specified.
+ * @function toObject
+ * @memberof Op
+ * @static
+ * @param {Op} message Op
+ * @param {$protobuf.IConversionOptions} [options] Conversion options
+ * @returns {Object.} Plain object
+ */
+ Op.toObject = function toObject(message, options) {
+ if (!options)
+ options = {};
+ let object = {};
+ if (options.arrays || options.defaults)
+ object.actions = [];
+ if (options.defaults)
+ object.entity = "";
+ if (message.entity != null && message.hasOwnProperty("entity"))
+ object.entity = message.entity;
+ if (message.actions && message.actions.length) {
+ object.actions = [];
+ for (let j = 0; j < message.actions.length; ++j)
+ object.actions[j] = message.actions[j];
+ }
+ return object;
+ };
+
+ /**
+ * Converts this Op to JSON.
+ * @function toJSON
+ * @memberof Op
+ * @instance
+ * @returns {Object.} JSON object
+ */
+ Op.prototype.toJSON = function toJSON() {
+ return this.constructor.toObject(this, $protobuf.util.toJSONOptions);
+ };
+
+ /**
+ * Gets the default type url for Op
+ * @function getTypeUrl
+ * @memberof Op
+ * @static
+ * @param {string} [typeUrlPrefix] your custom typeUrlPrefix(default "type.googleapis.com")
+ * @returns {string} The default type url
+ */
+ Op.getTypeUrl = function getTypeUrl(typeUrlPrefix) {
+ if (typeUrlPrefix === undefined) {
+ typeUrlPrefix = "type.googleapis.com";
+ }
+ return typeUrlPrefix + "/Op";
+ };
+
+ return Op;
+})();
+
+export { $root as default };
diff --git a/lib/macaroon-id.proto b/lib/macaroon-id.proto
new file mode 100644
index 00000000..764c0744
--- /dev/null
+++ b/lib/macaroon-id.proto
@@ -0,0 +1,14 @@
+syntax="proto3";
+
+option go_package = "macaroonpb";
+
+message MacaroonId {
+ bytes nonce = 1;
+ bytes storageId = 2;
+ repeated Op ops = 3;
+}
+
+message Op {
+ string entity = 1;
+ repeated string actions = 2;
+}
\ No newline at end of file
diff --git a/lib/macaroon.js b/lib/macaroon.js
new file mode 100644
index 00000000..80bab88a
--- /dev/null
+++ b/lib/macaroon.js
@@ -0,0 +1,206 @@
+import { importMacaroon, base64ToBytes } from 'macaroon'
+import { MacaroonId } from './macaroon-id'
+import isEqual from 'lodash/isEqual'
+import isEqualWith from 'lodash/isEqualWith'
+
+export const B64_REGEX = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
+export const HEX_REGEX = /^[0-9a-fA-F]+$/
+
+function decodeMacaroon (macaroon) {
+ if (HEX_REGEX.test(macaroon)) {
+ return importMacaroon(Buffer.from(macaroon, 'hex'))
+ }
+
+ if (B64_REGEX.test(macaroon)) {
+ return importMacaroon(Buffer.from(macaroon, 'base64'))
+ }
+
+ throw new Error('invalid macaroon encoding')
+}
+
+function macaroonOPs (macaroon) {
+ try {
+ const m = decodeMacaroon(macaroon)
+ const macJson = m.exportJSON()
+
+ if (macJson.i64) {
+ const identBytes = Buffer.from(base64ToBytes(macJson.i64))
+ if (identBytes[0] === 0x03) {
+ const id = MacaroonId.decode(identBytes.slice(1))
+ return id.toJSON().ops
+ }
+ }
+ } catch (e) {
+ console.error('macaroonOPs error:', e)
+ }
+
+ return []
+}
+
+function arrayCustomizer (value1, value2) {
+ if (Array.isArray(value1) && Array.isArray(value2)) {
+ value1.sort()
+ value2.sort()
+ return value1.length === value2.length &&
+ (isEqual(value1, value2) || value1.every((v, i) => isEqualWith(v, value2[i], arrayCustomizer)))
+ }
+}
+
+export function isInvoiceMacaroon (macaroon) {
+ return isEqualWith(macaroonOPs(macaroon), INVOICE_MACAROON_OPS, arrayCustomizer)
+}
+
+export function isAdminMacaroon (macaroon) {
+ return isEqualWith(macaroonOPs(macaroon), ADMIN_MACAROON_OPS, arrayCustomizer)
+}
+
+export function isReadOnlyMacaroon (macaroon) {
+ return isEqualWith(macaroonOPs(macaroon), READ_ONLY_MACAROON_OPS, arrayCustomizer)
+}
+
+const INVOICE_MACAROON_OPS = [
+ {
+ entity: 'address',
+ actions: [
+ 'read',
+ 'write'
+ ]
+ },
+ {
+ entity: 'invoices',
+ actions: [
+ 'read',
+ 'write'
+ ]
+ },
+ {
+ entity: 'onchain',
+ actions: [
+ 'read'
+ ]
+ }
+]
+
+const ADMIN_MACAROON_OPS = [
+ {
+ entity: 'address',
+ actions: [
+ 'read',
+ 'write'
+ ]
+ },
+ {
+ entity: 'info',
+ actions: [
+ 'read',
+ 'write'
+ ]
+ },
+ {
+ entity: 'invoices',
+ actions: [
+ 'read',
+ 'write'
+ ]
+ },
+ {
+ entity: 'macaroon',
+ actions: [
+ 'generate',
+ 'read',
+ 'write'
+ ]
+ },
+ {
+ entity: 'message',
+ actions: [
+ 'read',
+ 'write'
+ ]
+ },
+ {
+ entity: 'offchain',
+ actions: [
+ 'read',
+ 'write'
+ ]
+ },
+ {
+ entity: 'onchain',
+ actions: [
+ 'read',
+ 'write'
+ ]
+ },
+ {
+ entity: 'peers',
+ actions: [
+ 'read',
+ 'write'
+ ]
+ },
+ {
+ entity: 'signer',
+ actions: [
+ 'generate',
+ 'read'
+ ]
+ }
+]
+
+const READ_ONLY_MACAROON_OPS = [
+ {
+ entity: 'address',
+ actions: [
+ 'read'
+ ]
+ },
+ {
+ entity: 'info',
+ actions: [
+ 'read'
+ ]
+ },
+ {
+ entity: 'invoices',
+ actions: [
+ 'read'
+ ]
+ },
+ {
+ entity: 'macaroon',
+ actions: [
+ 'read'
+ ]
+ },
+ {
+ entity: 'message',
+ actions: [
+ 'read'
+ ]
+ },
+ {
+ entity: 'offchain',
+ actions: [
+ 'read'
+ ]
+ },
+ {
+ entity: 'onchain',
+ actions: [
+ 'read'
+ ]
+ },
+ {
+ entity: 'peers',
+ actions: [
+ 'read'
+ ]
+ },
+ {
+ entity: 'signer',
+ actions: [
+ 'read'
+ ]
+ }
+]
diff --git a/lib/validate.js b/lib/validate.js
index 4abc9e9d..5131a3ab 100644
--- a/lib/validate.js
+++ b/lib/validate.js
@@ -4,12 +4,12 @@ import {
MIN_POLL_NUM_CHOICES, MAX_FORWARDS, BOOST_MULT, MAX_TERRITORY_DESC_LENGTH, POST_TYPES,
TERRITORY_BILLING_TYPES, MAX_COMMENT_TEXT_LENGTH, MAX_POST_TEXT_LENGTH, MIN_TITLE_LENGTH, BOUNTY_MIN, BOUNTY_MAX, BALANCE_LIMIT_MSATS
} from './constants'
-import { URL_REGEXP, WS_REGEXP } from './url'
import { SUPPORTED_CURRENCIES } from './currency'
import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr'
import { msatsToSats, numWithUnits, abbrNum } from './format'
import * as usersFragments from '../fragments/users'
import * as subsFragments from '../fragments/subs'
+import { B64_REGEX, HEX_REGEX, isInvoiceMacaroon } from './macaroon'
const { SUB } = subsFragments
const { NAME_QUERY } = usersFragments
@@ -44,6 +44,63 @@ addMethod(string, 'or', function (schemas, msg) {
})
})
+addMethod(string, 'url', function (schemas, msg = 'invalid url') {
+ return this.test({
+ name: 'url',
+ message: msg,
+ test: value => {
+ try {
+ // eslint-disable-next-line no-new
+ new URL(value)
+ return true
+ } catch (e) {
+ try {
+ // eslint-disable-next-line no-new
+ new URL(`http://${value}`)
+ return true
+ } catch (e) {
+ return false
+ }
+ }
+ },
+ exclusive: false
+ })
+})
+
+addMethod(string, 'ws', function (schemas, msg = 'invalid websocket') {
+ return this.test({
+ name: 'ws',
+ message: msg,
+ test: value => {
+ try {
+ // eslint-disable-next-line no-new
+ new URL(value)
+ return true
+ } catch (e) {
+ return false
+ }
+ },
+ exclusive: false
+ })
+})
+
+addMethod(string, 'socket', function (schemas, msg = 'invalid socket') {
+ return this.test({
+ name: 'socket',
+ message: msg,
+ test: value => {
+ try {
+ const url = new URL(`http://${value}`)
+ return url.hostname && url.port && !url.username && !url.password &&
+ (!url.pathname || url.pathname === '/') && !url.search && !url.hash
+ } catch (e) {
+ return false
+ }
+ },
+ exclusive: false
+ })
+})
+
addMethod(string, 'https', function () {
return this.test({
name: 'https',
@@ -81,6 +138,11 @@ const lightningAddressValidator = process.env.NODE_ENV === 'development'
'address is no good')
: string().email('address is no good')
+const hexOrBase64Validator = string().or([
+ string().matches(HEX_REGEX),
+ string().matches(B64_REGEX)
+], 'invalid encoding')
+
async function usernameExists (name, { client, models }) {
if (!client && !models) {
throw new Error('cannot check for user')
@@ -220,14 +282,34 @@ export function advSchema (args) {
export function lnAddrAutowithdrawSchema ({ me } = {}) {
return object({
- lnAddr: lightningAddressValidator.required('required').test({
- name: 'lnAddr',
+ address: lightningAddressValidator.required('required').test({
+ name: 'address',
test: addr => !addr.endsWith('@stacker.news'),
message: 'automated withdrawals must be external'
}),
+ ...autowithdrawSchemaMembers({ me })
+ })
+}
+
+export function LNDAutowithdrawSchema ({ me } = {}) {
+ return object({
+ socket: string().socket().required('required'),
+ macaroon: hexOrBase64Validator.required('required').test({
+ name: 'macaroon',
+ test: isInvoiceMacaroon,
+ message: 'not an invoice macaroon'
+ }),
+ cert: hexOrBase64Validator,
+ ...autowithdrawSchemaMembers({ me })
+ })
+}
+
+export function autowithdrawSchemaMembers ({ me } = {}) {
+ return {
+ priority: boolean(),
autoWithdrawThreshold: intValidator.required('required').min(0, 'must be at least 0').max(msatsToSats(BALANCE_LIMIT_MSATS), `must be at most ${abbrNum(msatsToSats(BALANCE_LIMIT_MSATS))}`),
autoWithdrawMaxFeePercent: floatValidator.required('required').min(0, 'must be at least 0').max(50, 'must not exceed 50')
- })
+ }
}
export function bountySchema (args) {
@@ -263,7 +345,7 @@ export function linkSchema (args) {
return object({
title: titleValidator,
text: textValidator(MAX_POST_TEXT_LENGTH),
- url: string().matches(URL_REGEXP, 'invalid url').required('required'),
+ url: string().url().required('required'),
...advPostSchemaMembers(args),
...subSelectSchemaMembers(args)
}).test({
@@ -364,12 +446,12 @@ export const emailSchema = object({
})
export const urlSchema = object({
- url: string().matches(URL_REGEXP, 'invalid url').required('required')
+ url: string().url().required('required')
})
export const namedUrlSchema = object({
text: string().required('required').trim(),
- url: string().matches(URL_REGEXP, 'invalid url').required('required')
+ url: string().url().required('required')
})
export const amountSchema = object({
@@ -390,7 +472,7 @@ export const settingsSchema = object({
string().nullable().matches(NOSTR_PUBKEY_HEX, 'must be 64 hex chars'),
string().nullable().matches(NOSTR_PUBKEY_BECH32, 'invalid bech32 encoding')], 'invalid pubkey'),
nostrRelays: array().of(
- string().matches(WS_REGEXP, 'invalid web socket address')
+ string().ws()
).max(NOSTR_MAX_RELAY_NUM,
({ max, value }) => `${Math.abs(max - value.length)} too many`),
hideBookmarks: boolean(),
diff --git a/package-lock.json b/package-lock.json
index 2360d1ae..20021bcb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -33,11 +33,13 @@
"domino": "^2.1.6",
"formik": "^2.4.5",
"github-slugger": "^2.0.0",
+ "google-protobuf": "^3.21.2",
"graphql": "^16.8.1",
"graphql-scalar": "^0.1.0",
"graphql-tag": "^2.12.6",
"graphql-type-json": "^0.3.2",
"ln-service": "^57.1.3",
+ "macaroon": "^3.0.4",
"mathjs": "^11.11.2",
"mdast-util-find-and-replace": "^3.0.1",
"mdast-util-from-markdown": "^2.0.0",
@@ -7250,6 +7252,11 @@
"node": ">=4"
}
},
+ "node_modules/google-protobuf": {
+ "version": "3.21.2",
+ "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.21.2.tgz",
+ "integrity": "sha512-3MSOYFO5U9mPGikIYCzK0SaThypfGgS6bHqrUGXG3DPHCrb+txNqeEcns1W0lkGfk0rCyNXm7xB9rMxnCiZOoA=="
+ },
"node_modules/gopd": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
diff --git a/package.json b/package.json
index c4bae7d7..3a758fae 100644
--- a/package.json
+++ b/package.json
@@ -36,11 +36,13 @@
"domino": "^2.1.6",
"formik": "^2.4.5",
"github-slugger": "^2.0.0",
+ "google-protobuf": "^3.21.2",
"graphql": "^16.8.1",
"graphql-scalar": "^0.1.0",
"graphql-tag": "^2.12.6",
"graphql-type-json": "^0.3.2",
"ln-service": "^57.1.3",
+ "macaroon": "^3.0.4",
"mathjs": "^11.11.2",
"mdast-util-find-and-replace": "^3.0.1",
"mdast-util-from-markdown": "^2.0.0",
diff --git a/pages/settings/wallets/index.js b/pages/settings/wallets/index.js
index a03b5589..a9ffe44c 100644
--- a/pages/settings/wallets/index.js
+++ b/pages/settings/wallets/index.js
@@ -5,17 +5,29 @@ import { WalletCard } from '../../../components/wallet-card'
import { LightningAddressWalletCard } from './lightning-address'
import { LNbitsCard } from './lnbits'
import { NWCCard } from './nwc'
+import { LNDCard } from './lnd'
+import { WALLETS } from '../../../fragments/wallet'
+import { useQuery } from '@apollo/client'
+import PageLoading from '../../../components/page-loading'
-export const getServerSideProps = getGetServerSideProps({ authRequired: true })
+export const getServerSideProps = getGetServerSideProps({ query: WALLETS, authRequired: true })
+
+export default function Wallet ({ ssrData }) {
+ const { data } = useQuery(WALLETS)
+
+ if (!data && !ssrData) return
+ const { wallets } = data || ssrData
+ const lnd = wallets.find(w => w.type === 'LND')
+ const lnaddr = wallets.find(w => w.type === 'LIGHTNING_ADDRESS')
-export default function Wallet () {
return (
attach wallets
attach wallets to supplement your SN wallet
-
+
+
diff --git a/pages/settings/wallets/lightning-address.js b/pages/settings/wallets/lightning-address.js
index f8605de1..d3301dfb 100644
--- a/pages/settings/wallets/lightning-address.js
+++ b/pages/settings/wallets/lightning-address.js
@@ -1,92 +1,70 @@
-import { InputGroup } from 'react-bootstrap'
import { getGetServerSideProps } from '../../../api/ssrApollo'
import { Form, Input } from '../../../components/form'
import { CenterLayout } from '../../../components/layout'
import { useMe } from '../../../components/me'
import { WalletButtonBar, WalletCard } from '../../../components/wallet-card'
import { useMutation } from '@apollo/client'
-import { REMOVE_AUTOWITHDRAW, SET_AUTOWITHDRAW } from '../../../fragments/users'
import { useToast } from '../../../components/toast'
-import { lnAddrAutowithdrawSchema, isNumber } from '../../../lib/validate'
+import { lnAddrAutowithdrawSchema } from '../../../lib/validate'
import { useRouter } from 'next/router'
-import { useEffect, useState } from 'react'
-import { numWithUnits } from '../../../lib/format'
+import { AutowithdrawSettings, autowithdrawInitial } from '../../../components/autowithdraw-shared'
+import { REMOVE_WALLET, UPSERT_WALLET_LNADDR, WALLET_BY_TYPE } from '../../../fragments/wallet'
-export const getServerSideProps = getGetServerSideProps({ authRequired: true })
+const variables = { type: 'LIGHTNING_ADDRESS' }
+export const getServerSideProps = getGetServerSideProps({ query: WALLET_BY_TYPE, variables, authRequired: true })
-function useAutoWithdrawEnabled () {
- const me = useMe()
- return me?.privates?.lnAddr && isNumber(me?.privates?.autoWithdrawThreshold) && isNumber(me?.privates?.autoWithdrawMaxFeePercent)
-}
-
-export default function LightningAddress () {
+export default function LightningAddress ({ ssrData }) {
const me = useMe()
const toaster = useToast()
const router = useRouter()
- const [setAutoWithdraw] = useMutation(SET_AUTOWITHDRAW)
- const enabled = useAutoWithdrawEnabled()
- const [removeAutoWithdraw] = useMutation(REMOVE_AUTOWITHDRAW)
- const autoWithdrawThreshold = isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000
- const [sendThreshold, setSendThreshold] = useState(Math.max(Math.floor(autoWithdrawThreshold / 10), 1))
+ const [upsertWalletLNAddr] = useMutation(UPSERT_WALLET_LNADDR)
+ const [removeWallet] = useMutation(REMOVE_WALLET)
- useEffect(() => {
- setSendThreshold(Math.max(Math.floor(me?.privates?.autoWithdrawThreshold / 10), 1))
- }, [autoWithdrawThreshold])
+ const { walletByType: wallet } = ssrData || {}
return (
lightning address
- autowithdraw to a lightning address to maintain desired balance
+ autowithdraw to a lightning address