LND autowithdraw (#806)

* wip

* wip

* improved validatation, test connection before save, code reuse

* worker send to lnd

* autowithdraw priority
This commit is contained in:
Keyan 2024-02-13 13:17:56 -06:00 committed by GitHub
parent fc18a917e3
commit ec4e1b5da7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1663 additions and 159 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<>
<Checkbox
label='make default autowithdraw method'
id='priority'
name='priority'
/>
<div className='my-4 border border-3 rounded'>
<div className='p-3'>
<h3 className='text-center text-muted'>desired balance</h3>
<h6 className='text-center pb-3'>applies globally to all autowithdraw methods</h6>
<Input
label='desired balance'
name='autoWithdrawThreshold'
onChange={(formik, e) => {
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={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<Input
label='max fee'
name='autoWithdrawMaxFeePercent'
hint='max fee as percent of withdrawal amount'
append={<InputGroup.Text>%</InputGroup.Text>}
/>
</div>
</div>
</>
)
}

View File

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

View File

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

546
lib/macaroon-id.js Normal file
View File

@ -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.<IOp>|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.<IOp>} 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.<string,*>} 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.<string,*>} 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.<string,*>} 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.<string,*>} 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.<string>|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.<string>} 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.<string,*>} 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.<string,*>} 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.<string,*>} 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.<string,*>} 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 };

14
lib/macaroon-id.proto Normal file
View File

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

206
lib/macaroon.js Normal file
View File

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

View File

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

7
package-lock.json generated
View File

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

View File

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

View File

@ -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 <PageLoading />
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 (
<Layout>
<div className='py-5 w-100'>
<h2 className='mb-2 text-center'>attach wallets</h2>
<h6 className='text-muted text-center'>attach wallets to supplement your SN wallet</h6>
<div className={styles.walletGrid}>
<LightningAddressWalletCard />
<LightningAddressWalletCard wallet={lnaddr} />
<LNDCard wallet={lnd} />
<LNbitsCard />
<NWCCard />
<WalletCard title='coming soon' badges={['probably']} />

View File

@ -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 (
<CenterLayout>
<h2 className='pb-2'>lightning address</h2>
<h6 className='text-muted text-center pb-3'>autowithdraw to a lightning address to maintain desired balance</h6>
<h6 className='text-muted text-center pb-3'>autowithdraw to a lightning address</h6>
<Form
initial={{
lnAddr: me?.privates?.lnAddr || '',
autoWithdrawThreshold,
autoWithdrawMaxFeePercent: isNumber(me?.privates?.autoWithdrawMaxFeePercent) ? me?.privates?.autoWithdrawMaxFeePercent : 1
address: wallet?.wallet?.address || '',
...autowithdrawInitial({ me, priority: wallet?.priority })
}}
schema={lnAddrAutowithdrawSchema({ me })}
onSubmit={async ({ autoWithdrawThreshold, autoWithdrawMaxFeePercent, ...values }) => {
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?.())
}
}}
>
<Input
label='lightning address'
name='lnAddr'
name='address'
autoComplete='off'
required
autoFocus
/>
<Input
label='desired balance'
name='autoWithdrawThreshold'
onChange={(formik, e) => {
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={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>
<Input
label='max fee'
name='autoWithdrawMaxFeePercent'
hint='max fee as percent of withdrawal amount'
append={<InputGroup.Text>%</InputGroup.Text>}
/>
<AutowithdrawSettings />
<WalletButtonBar
enabled={enabled} onDelete={async () => {
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 (
<WalletCard
title='lightning address'
badges={['receive only', 'non-custodialish']}
provider='lightning-address'
enabled={enabled}
enabled={wallet !== undefined || undefined}
/>
)
}

View File

@ -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 (
<CenterLayout>
<h2 className='pb-2'>LND</h2>
<h6 className='text-muted text-center pb-3'>autowithdraw to your Lightning Labs node</h6>
<Form
initial={{
socket: wallet?.wallet?.socket || '',
macaroon: wallet?.wallet?.macaroon || '',
cert: wallet?.wallet?.cert || '',
...autowithdrawInitial({ me, priority: wallet?.priority })
}}
schema={LNDAutowithdrawSchema({ me })}
onSubmit={async ({ socket, cert, macaroon, ...settings }) => {
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?.())
}
}}
>
<Input
label='grpc host and port'
name='socket'
hint='tor or clearnet'
placeholder='55.5.555.55:10001'
required
autoFocus
/>
<Input
label='invoice macaroon'
name='macaroon'
hint='hex or base64 encoded'
placeholder='AgEDbG5kAlgDChCn7YgfWX7uTkQQgXZ2uahNEgEwGhYKB2FkZHJlc3MSBHJlYWQSBXdyaXRlGhcKCGludm9pY2VzEgRyZWFkEgV3cml0ZRoPCgdvbmNoYWluEgRyZWFkAAAGIJkMBrrDV0npU90JV0TGNJPrqUD8m2QYoTDjolaL6eBs'
required
/>
<Input
label={<>cert <small className='text-muted ms-2'>optional if from CA (e.g. voltage)</small></>}
name='cert'
hint='hex or base64 encoded'
placeholder='LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K'
/>
<AutowithdrawSettings />
<WalletButtonBar
enabled={!!wallet} onDelete={async () => {
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?.())
}
}}
/>
</Form>
</CenterLayout>
)
}
export function LNDCard ({ wallet }) {
return (
<WalletCard
title='LND'
badges={['receive only', 'non-custodial']}
provider='lnd'
enabled={wallet !== undefined || undefined}
/>
)
}

View File

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

View File

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

126
worker/autowithdraw.js Normal file
View File

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

View File

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

View File

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