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 ( + <> + +
+
+

desired balance

+
applies globally to all autowithdraw methods
+ { + const value = e.target.value + setSendThreshold(Math.max(Math.floor(value / 10), 1)) + }} + hint={isNumber(sendThreshold) ? `attempts to keep your balance no greater than ${numWithUnits(sendThreshold)} of this amount` : undefined} + append={sats} + /> + %} + /> +
+
+ + + ) +} 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
{ + onSubmit={async ({ address, ...settings }) => { try { - await setAutoWithdraw({ + await upsertWalletLNAddr({ variables: { - lnAddr: values.lnAddr, - autoWithdrawThreshold: Number(autoWithdrawThreshold), - autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent) + id: wallet?.id, + address, + settings: { + ...settings, + autoWithdrawThreshold: Number(settings.autoWithdrawThreshold), + autoWithdrawMaxFeePercent: Number(settings.autoWithdrawMaxFeePercent) + } } }) toaster.success('saved settings') router.push('/settings/wallets') } catch (err) { console.error(err) - toaster.danger('failed to attach:' + err.message || err.toString?.()) + toaster.danger('failed to attach: ' + err.message || err.toString?.()) } }} > - { - const value = e.target.value - setSendThreshold(Math.max(Math.floor(value / 10), 1)) - }} - hint={isNumber(sendThreshold) ? `note: attempts to keep your balance within ${numWithUnits(sendThreshold)} of this amount` : undefined} - append={sats} - /> - %} - /> + { + enabled={!!wallet} onDelete={async () => { try { - await removeAutoWithdraw() + await removeWallet({ variables: { id: wallet?.id } }) toaster.success('saved settings') router.push('/settings/wallets') } catch (err) { @@ -100,15 +78,13 @@ export default function LightningAddress () { ) } -export function LightningAddressWalletCard () { - const enabled = useAutoWithdrawEnabled() - +export function LightningAddressWalletCard ({ wallet }) { return ( ) } diff --git a/pages/settings/wallets/lnd.js b/pages/settings/wallets/lnd.js new file mode 100644 index 00000000..99e306dc --- /dev/null +++ b/pages/settings/wallets/lnd.js @@ -0,0 +1,108 @@ +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 { useToast } from '../../../components/toast' +import { LNDAutowithdrawSchema } from '../../../lib/validate' +import { useRouter } from 'next/router' +import { AutowithdrawSettings, autowithdrawInitial } from '../../../components/autowithdraw-shared' +import { REMOVE_WALLET, UPSERT_WALLET_LND, WALLET_BY_TYPE } from '../../../fragments/wallet' + +const variables = { type: 'LND' } +export const getServerSideProps = getGetServerSideProps({ query: WALLET_BY_TYPE, variables, authRequired: true }) + +export default function LND ({ ssrData }) { + const me = useMe() + const toaster = useToast() + const router = useRouter() + const [upsertWalletLND] = useMutation(UPSERT_WALLET_LND) + const [removeWallet] = useMutation(REMOVE_WALLET) + + const { walletByType: wallet } = ssrData || {} + + return ( + +

LND

+
autowithdraw to your Lightning Labs node
+ { + try { + await upsertWalletLND({ + variables: { + id: wallet?.id, + socket, + macaroon, + cert, + settings: { + ...settings, + autoWithdrawThreshold: Number(settings.autoWithdrawThreshold), + autoWithdrawMaxFeePercent: Number(settings.autoWithdrawMaxFeePercent) + } + } + }) + toaster.success('saved settings') + router.push('/settings/wallets') + } catch (err) { + console.error(err) + toaster.danger('failed to attach: ' + err.message || err.toString?.()) + } + }} + > + + + cert optional if from CA (e.g. voltage)} + name='cert' + hint='hex or base64 encoded' + placeholder='LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K' + /> + + { + try { + await removeWallet({ variables: { id: wallet?.id } }) + toaster.success('saved settings') + router.push('/settings/wallets') + } catch (err) { + console.error(err) + toaster.danger('failed to unattach:' + err.message || err.toString?.()) + } + }} + /> + +
+ ) +} + +export function LNDCard ({ wallet }) { + return ( + + ) +} diff --git a/prisma/migrations/20240209013150_attach_wallets/migration.sql b/prisma/migrations/20240209013150_attach_wallets/migration.sql new file mode 100644 index 00000000..288221b2 --- /dev/null +++ b/prisma/migrations/20240209013150_attach_wallets/migration.sql @@ -0,0 +1,131 @@ +-- CreateEnum +CREATE TYPE "WalletType" AS ENUM ('LIGHTNING_ADDRESS', 'LND'); + +-- CreateTable +CREATE TABLE "Wallet" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" INTEGER NOT NULL, + "label" TEXT, + "priority" INTEGER NOT NULL DEFAULT 0, + "type" "WalletType" NOT NULL, + "wallet" JSONB, + + CONSTRAINT "Wallet_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletLightningAddress" ( + "id" 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, + "address" TEXT NOT NULL, + + CONSTRAINT "WalletLightningAddress_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "WalletLND" ( + "id" 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, + "socket" TEXT NOT NULL, + "macaroon" TEXT NOT NULL, + "cert" TEXT, + + CONSTRAINT "WalletLND_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Wallet_userId_idx" ON "Wallet"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLightningAddress_walletId_key" ON "WalletLightningAddress"("walletId"); + +-- CreateIndex +CREATE UNIQUE INDEX "WalletLND_walletId_key" ON "WalletLND"("walletId"); + +-- AddForeignKey +ALTER TABLE "Wallet" ADD CONSTRAINT "Wallet_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletLightningAddress" ADD CONSTRAINT "WalletLightningAddress_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "WalletLND" ADD CONSTRAINT "WalletLND_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateTriggers to update wallet column in Wallet table +CREATE OR REPLACE FUNCTION wallet_wallet_type_as_jsonb() +RETURNS TRIGGER AS $$ +BEGIN + IF (TG_OP = 'INSERT' OR TG_OP = 'UPDATE') THEN + UPDATE "Wallet" + SET wallet = to_jsonb(NEW) + WHERE id = NEW."walletId"; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER wallet_lnaddr_as_jsonb +AFTER INSERT OR UPDATE ON "WalletLightningAddress" +FOR EACH ROW EXECUTE PROCEDURE wallet_wallet_type_as_jsonb(); + +CREATE TRIGGER wallet_lnd_as_jsonb +AFTER INSERT OR UPDATE ON "WalletLND" +FOR EACH ROW EXECUTE PROCEDURE wallet_wallet_type_as_jsonb(); + +-- migrate lnaddr from users to use wallet table +-- we leave the lnaddr column in users for now to avoid breaking production on deploy +WITH users AS ( + SELECT users.id AS "userId", "lnAddr" + FROM users + WHERE "lnAddr" IS NOT NULL +), +wallets AS ( + INSERT INTO "Wallet" ("userId", "type") + SELECT "userId", 'LIGHTNING_ADDRESS' + FROM users + RETURNING * +) +INSERT INTO "WalletLightningAddress" ("walletId", "address") +SELECT wallets.id, users."lnAddr" +FROM users +JOIN wallets ON users."userId" = wallets."userId"; + +CREATE OR REPLACE FUNCTION user_auto_withdraw() RETURNS TRIGGER AS $$ +DECLARE +BEGIN + INSERT INTO pgboss.job (name, data) + SELECT 'autoWithdraw', jsonb_build_object('id', NEW.id) + -- only if there isn't already a pending job for this user + WHERE NOT EXISTS ( + SELECT * + FROM pgboss.job + WHERE name = 'autoWithdraw' + AND data->>'id' = NEW.id::TEXT + AND state = 'created' + ) + -- and they have an attached wallet (currently all are received only) + AND EXISTS ( + SELECT * + FROM "Wallet" + WHERE "userId" = NEW.id + ); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS user_auto_withdraw_trigger ON users; +CREATE TRIGGER user_auto_withdraw_trigger + AFTER UPDATE ON users + FOR EACH ROW + WHEN ( + NEW."autoWithdrawThreshold" IS NOT NULL + AND NEW."autoWithdrawMaxFeePercent" IS NOT NULL + -- in excess of at least 10% of the threshold + AND NEW.msats - (NEW."autoWithdrawThreshold" * 1000) >= NEW."autoWithdrawThreshold" * 1000 * 0.1) + EXECUTE PROCEDURE user_auto_withdraw(); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8d7c1c55..9208188f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -107,6 +107,7 @@ model User { Sub Sub[] SubAct SubAct[] MuteSub MuteSub[] + Wallet Wallet[] @@index([photoId]) @@index([createdAt], map: "users.created_at_index") @@ -114,6 +115,54 @@ model User { @@map("users") } +enum WalletType { + LIGHTNING_ADDRESS + LND +} + +model Wallet { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + userId Int + label String? + priority Int @default(0) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + // NOTE: this denormalized json field exists to make polymorphic joins efficient + // when reading wallets ... it is populated by a trigger when wallet descendants update + // otherwise reading wallets would require a join on every descendant table + // which might not be numerous for wallets but would be for other tables + // so this is a pattern we use only to be consistent with future polymorphic tables + // because it gives us fast reads and type safe writes + type WalletType + wallet Json? @db.JsonB + walletLightningAddress WalletLightningAddress? + walletLND WalletLND? + + @@index([userId]) +} + +model WalletLightningAddress { + id 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") + address String +} + +model WalletLND { + id 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") + socket String + macaroon String + cert String? +} + model Mute { muterId Int mutedId Int diff --git a/worker/autowithdraw.js b/worker/autowithdraw.js new file mode 100644 index 00000000..600a106d --- /dev/null +++ b/worker/autowithdraw.js @@ -0,0 +1,126 @@ +import { authenticatedLndGrpc, createInvoice } from 'ln-service' +import { msatsToSats, satsToMsats } from '../lib/format' +import { datePivot } from '../lib/time' +import { createWithdrawal, sendToLnAddr } from '../api/resolvers/wallet' + +export async function autoWithdraw ({ data: { id }, models, lnd }) { + const user = await models.user.findUnique({ where: { id } }) + if (user.autoWithdrawThreshold === null || user.autoWithdrawMaxFeePercent === null) return + + const threshold = satsToMsats(user.autoWithdrawThreshold) + const excess = Number(user.msats - threshold) + + // excess must be greater than 10% of threshold + if (excess < Number(threshold) * 0.1) return + + const maxFee = msatsToSats(Math.ceil(excess * (user.autoWithdrawMaxFeePercent / 100.0))) + const amount = msatsToSats(excess) - maxFee + + // must be >= 1 sat + if (amount < 1) return + + // check that + // 1. the user doesn't have an autowithdraw pending + // 2. we have not already attempted to autowithdraw this fee recently + const [pendingOrFailed] = await models.$queryRaw` + SELECT EXISTS( + SELECT * + FROM "Withdrawl" + WHERE "userId" = ${id} AND "autoWithdraw" + AND (status IS NULL + OR ( + status <> 'CONFIRMED' AND + now() < created_at + interval '1 hour' AND + "msatsFeePaying" >= ${satsToMsats(maxFee)} + )) + )` + + if (pendingOrFailed.exists) return + + // get the wallets in order of priority + const wallets = await models.wallet.findMany({ + where: { userId: user.id }, + orderBy: { priority: 'desc' } + }) + + for (const wallet of wallets) { + try { + if (wallet.type === 'LND') { + await autowithdrawLND( + { amount, maxFee }, + { models, me: user, lnd }) + } else if (wallet.type === 'LIGHTNING_ADDRESS') { + await autowithdrawLNAddr( + { amount, maxFee }, + { models, me: user, lnd }) + } + + return + } catch (error) { + console.error(error) + } + } + + // none of the wallets worked +} + +async function autowithdrawLNAddr ( + { amount, maxFee }, + { me, models, lnd, headers, autoWithdraw = false }) { + if (!me) { + throw new Error('me not specified') + } + + const wallet = await models.wallet.findFirst({ + where: { + userId: me.id, + type: 'LIGHTNING_ADDRESS' + }, + include: { + walletLightningAddress: true + } + }) + + if (!wallet || !wallet.walletLightningAddress) { + throw new Error('no lightning address wallet found') + } + + const { walletLND: { address } } = wallet + return await sendToLnAddr(null, { addr: address, amount, maxFee }, { me, models, lnd, autoWithdraw: true }) +} + +async function autowithdrawLND ({ amount, maxFee }, { me, models, lnd }) { + if (!me) { + throw new Error('me not specified') + } + + const wallet = await models.wallet.findFirst({ + where: { + userId: me.id, + type: 'LND' + }, + include: { + walletLND: true + } + }) + + if (!wallet || !wallet.walletLND) { + throw new Error('no lightning address wallet found') + } + + const { walletLND: { cert, macaroon, socket } } = wallet + const { lnd: lndOut } = await authenticatedLndGrpc({ + cert, + macaroon, + socket + }) + + const invoice = await createInvoice({ + description: me.hideInvoiceDesc ? undefined : 'autowithdraw to LND from SN', + lnd: lndOut, + tokens: amount, + expires_at: datePivot(new Date(), { seconds: 360 }) + }) + + return await createWithdrawal(null, { invoice: invoice.request, maxFee }, { me, models, lnd, autoWithdraw: true }) +} diff --git a/worker/index.js b/worker/index.js index 66d7bb80..a7de7eb3 100644 --- a/worker/index.js +++ b/worker/index.js @@ -3,7 +3,7 @@ import nextEnv from '@next/env' import { PrismaClient } from '@prisma/client' import { autoDropBolt11s, checkPendingDeposits, checkPendingWithdrawals, - finalizeHodlInvoice, subscribeToWallet, autoWithdraw + finalizeHodlInvoice, subscribeToWallet } from './wallet.js' import { repin } from './repin.js' import { trust } from './trust.js' @@ -22,6 +22,7 @@ import { deleteItem } from './ephemeralItems.js' import { deleteUnusedImages } from './deleteUnusedImages.js' import { territoryBilling } from './territory.js' import { ofac } from './ofac.js' +import { autoWithdraw } from './autowithdraw.js' const { loadEnvConfig } = nextEnv const { ApolloClient, HttpLink, InMemoryCache } = apolloClient diff --git a/worker/wallet.js b/worker/wallet.js index 732cbd30..252166df 100644 --- a/worker/wallet.js +++ b/worker/wallet.js @@ -4,12 +4,10 @@ import { subscribeToInvoices, subscribeToPayments, subscribeToInvoice } from 'ln-service' import { sendUserNotification } from '../api/webPush/index.js' -import { msatsToSats, numWithUnits, satsToMsats } from '../lib/format' +import { msatsToSats, numWithUnits } from '../lib/format' import { INVOICE_RETENTION_DAYS } from '../lib/constants' import { datePivot, sleep } from '../lib/time.js' -import { sendToLnAddr } from '../api/resolvers/wallet.js' import retry from 'async-retry' -import { isNumber } from '../lib/validate.js' export async function subscribeToWallet (args) { await subscribeToDeposits(args) @@ -304,46 +302,3 @@ export async function checkPendingWithdrawals (args) { } } } - -export async function autoWithdraw ({ data: { id }, models, lnd }) { - const user = await models.user.findUnique({ where: { id } }) - if (!user || - !user.lnAddr || - !isNumber(user.autoWithdrawThreshold) || - !isNumber(user.autoWithdrawMaxFeePercent)) return - - const threshold = satsToMsats(user.autoWithdrawThreshold) - const excess = Number(user.msats - threshold) - - // excess must be greater than 10% of threshold - if (excess < Number(threshold) * 0.1) return - - const maxFee = msatsToSats(Math.ceil(excess * (user.autoWithdrawMaxFeePercent / 100.0))) - const amount = msatsToSats(excess) - maxFee - - // must be >= 1 sat - if (amount < 1) return - - // check that - // 1. the user doesn't have an autowithdraw pending - // 2. we have not already attempted to autowithdraw this fee recently - const [pendingOrFailed] = await models.$queryRaw` - SELECT EXISTS( - SELECT * - FROM "Withdrawl" - WHERE "userId" = ${id} AND "autoWithdraw" - AND (status IS NULL - OR ( - status <> 'CONFIRMED' AND - now() < created_at + interval '1 hour' AND - "msatsFeePaying" >= ${satsToMsats(maxFee)} - )) - )` - - if (pendingOrFailed.exists) return - - await sendToLnAddr( - null, - { addr: user.lnAddr, amount, maxFee }, - { models, me: user, lnd, autoWithdraw: true }) -}