LND autowithdraw (#806)
* wip * wip * improved validatation, test connection before save, code reuse * worker send to lnd * autowithdraw priority
This commit is contained in:
parent
fc18a917e3
commit
ec4e1b5da7
|
@ -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' } })
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
`
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
`
|
||||
|
|
|
@ -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 };
|
|
@ -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;
|
||||
}
|
|
@ -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'
|
||||
]
|
||||
}
|
||||
]
|
|
@ -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(),
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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']} />
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -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();
|
|
@ -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
|
||||
|
|
|
@ -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 })
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue