server side config saves

This commit is contained in:
k00b 2024-10-24 15:30:56 -05:00
parent 4826ae5a7b
commit ccdf346954
24 changed files with 325 additions and 371 deletions

View File

@ -55,8 +55,8 @@ export default {
for (const entry of entries) {
txs.push(models.vaultEntry.update({
where: { id: entry.id },
data: { key: entry.key, value: entry.value }
where: { userId_key: { userId: me.id, key: entry.key } },
data: { value: entry.value }
}))
}
await models.prisma.$transaction(txs)

View File

@ -26,12 +26,13 @@ import { getNodeSockets, getOurPubkey } from '../lnd'
function injectResolvers (resolvers) {
console.group('injected GraphQL resolvers:')
for (const w of walletDefs) {
const resolverName = generateResolverName(w.walletField)
for (const walletDef of walletDefs) {
const resolverName = generateResolverName(walletDef.walletField)
console.log(resolverName)
resolvers.Mutation[resolverName] = async (parent, { settings, validateLightning, vaultEntries, ...data }, { me, models }) => {
// allow transformation of the data on validation (this is optional ... won't do anything if not implemented)
const validData = await walletValidate(w, { ...data, ...settings, vaultEntries })
// TODO: our validation should be improved
const validData = await walletValidate(walletDef, { ...data, ...settings, vaultEntries })
if (validData) {
Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] })
Object.keys(validData).filter(key => key in settings).forEach(key => { settings[key] = validData[key] })
@ -39,10 +40,12 @@ function injectResolvers (resolvers) {
return await upsertWallet({
wallet: {
field: w.walletField,
type: w.walletType
field: walletDef.walletField,
type: walletDef.walletType
},
testCreateInvoice: w.testCreateInvoice && validateLightning ? (data) => w.testCreateInvoice(data, { me, models }) : null
testCreateInvoice: walletDef.testCreateInvoice && validateLightning
? (data) => walletDef.testCreateInvoice(data, { me, models })
: null
}, {
settings,
data,
@ -643,17 +646,13 @@ export const addWalletLog = async ({ wallet, level, message }, { models }) => {
}
async function upsertWallet (
{ wallet, testCreateInvoice },
{ settings, data, priorityOnly, canSend, canReceive },
{ me, models }
) {
if (!me) throw new GqlAuthenticationError()
{ wallet, testCreateInvoice }, { settings, data, vaultEntries = [] }, { me, models }) {
if (!me) {
throw new GqlAuthenticationError()
}
assertApiKeyNotPermitted({ me })
const { id, ...walletData } = data
const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, enabled, priority } = settings
if (testCreateInvoice && !priorityOnly && canReceive && enabled) {
if (testCreateInvoice) {
try {
await testCreateInvoice(data)
} catch (err) {
@ -666,103 +665,111 @@ async function upsertWallet (
}
}
return await models.$transaction(async (tx) => {
if (canReceive) {
await tx.user.update({
where: { id: me.id },
data: {
autoWithdrawMaxFeePercent,
autoWithdrawThreshold
}
})
}
const { id, enabled, priority, ...walletData } = data
const {
autoWithdrawThreshold,
autoWithdrawMaxFeePercent,
autoWithdrawMaxFeeTotal
} = settings
let updatedWallet
if (id) {
const existingWalletTypeRecord = canReceive
? await tx[wallet.field].findUnique({
where: { walletId: Number(id) }
})
: undefined
const txs = []
updatedWallet = await tx.wallet.update({
if (id) {
const oldVaultEntries = await models.vaultEntry.findMany({ where: { userId: me.id, walletId: Number(id) } })
// createMany is the set difference of the new - old
// deleteMany is the set difference of the old - new
// updateMany is the intersection of the old and new
const difference = (a = [], b = [], key = 'key') => a.filter(x => !b.find(y => y[key] === x[key]))
const intersectionMerge = (a = [], b = [], key = 'key') => a.filter(x => b.find(y => y[key] === x[key]))
.map(x => ({ [key]: x[key], ...b.find(y => y[key] === x[key]) }))
txs.push(
models.wallet.update({
where: { id: Number(id), userId: me.id },
data: {
enabled,
priority,
canSend,
canReceive,
// if send-only config or priority only, don't update the wallet type record
...(canReceive && !priorityOnly
? {
[wallet.field]: existingWalletTypeRecord
? { update: walletData }
: { create: walletData }
}
: {})
[wallet.field]: {
update: {
where: { walletId: Number(id) },
data: walletData
}
},
vaultEntries: {
deleteMany: difference(oldVaultEntries, vaultEntries, 'key').map(({ key }) => ({
userId: me.id, key
})),
create: difference(vaultEntries, oldVaultEntries, 'key').map(({ key, value }) => ({
key, value, userId: me.id
})),
update: intersectionMerge(oldVaultEntries, vaultEntries, 'key').map(({ key, value }) => ({
where: { userId_key: { userId: me.id, key } },
data: { value }
}))
}
},
include: {
...(canReceive && !priorityOnly ? { [wallet.field]: true } : {})
vaultEntries: true
}
})
} else {
updatedWallet = await tx.wallet.create({
)
} else {
txs.push(
models.wallet.create({
include: {
vaultEntries: true
},
data: {
enabled,
priority,
canSend,
canReceive,
userId: me.id,
type: wallet.type,
// if send-only config or priority only, don't update the wallet type record
...(canReceive && !priorityOnly
? {
[wallet.field]: {
create: walletData
}
}
: {})
[wallet.field]: {
create: walletData
},
vaultEntries: {
createMany: {
data: vaultEntries.map(({ key, value }) => ({ key, value, userId: me.id }))
}
}
}
})
}
)
}
const logs = []
if (canReceive) {
logs.push({
userId: me.id,
wallet: wallet.type,
level: enabled ? 'SUCCESS' : 'INFO',
message: id ? 'receive details updated' : 'wallet attached for receives'
})
logs.push({
userId: me.id,
wallet: wallet.type,
level: enabled ? 'SUCCESS' : 'INFO',
message: enabled ? 'receives enabled' : 'receives disabled'
})
}
if (canSend) {
logs.push({
userId: me.id,
wallet: wallet.type,
level: enabled ? 'SUCCESS' : 'INFO',
message: id ? 'send details updated' : 'wallet attached for sends'
})
logs.push({
userId: me.id,
wallet: wallet.type,
level: enabled ? 'SUCCESS' : 'INFO',
message: enabled ? 'sends enabled' : 'sends disabled'
})
}
await tx.walletLog.createMany({
data: logs
txs.push(
models.user.update({
where: { id: me.id },
data: {
autoWithdrawMaxFeePercent,
autoWithdrawThreshold,
autoWithdrawMaxFeeTotal
}
})
)
return updatedWallet
})
txs.push(
models.walletLog.createMany({
data: {
userId: me.id,
wallet: wallet.type,
level: 'SUCCESS',
message: id ? 'wallet details updated' : 'wallet attached'
}
}),
models.walletLog.create({
data: {
userId: me.id,
wallet: wallet.type,
level: enabled ? 'SUCCESS' : 'INFO',
message: enabled ? 'wallet enabled' : 'wallet disabled'
}
})
)
const [upsertedWallet] = await models.$transaction(txs)
return upsertedWallet
}
export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, walletId = null }) {

View File

@ -182,11 +182,9 @@ export default gql`
withdrawMaxFeeDefault: Int!
autoWithdrawThreshold: Int
autoWithdrawMaxFeePercent: Float
<<<<<<< HEAD
autoWithdrawMaxFeeTotal: Int
=======
vaultKeyHash: String
>>>>>>> 002b1d19 (user vault and server side client wallets)
walletsUpdatedAt: Date
}
type UserOptional {

View File

@ -17,7 +17,7 @@ function mutationTypeDefs () {
.filter(isServerField)
.map(fieldToGqlArgOptional)
if (serverFields.length > 0) args += serverFields.join(', ') + ','
args += 'settings: AutowithdrawSettings!, priorityOnly: Boolean, canSend: Boolean!, canReceive: Boolean!'
args += 'enabled: Boolean, priority: Int, vaultEntries: [VaultEntryInput!], settings: AutowithdrawSettings!, validateLightning: Boolean'
const resolverName = generateResolverName(w.walletField)
const typeDef = `${resolverName}(${args}): Wallet`
console.log(typeDef)
@ -91,8 +91,6 @@ const typeDefs = `
enabled: Boolean!
priority: Int!
wallet: WalletDetails!
canReceive: Boolean!
canSend: Boolean!
vaultEntries: [VaultEntry!]!
}
@ -100,8 +98,6 @@ const typeDefs = `
autoWithdrawThreshold: Int!
autoWithdrawMaxFeePercent: Float!
autoWithdrawMaxFeeTotal: Int!
priority: Int
enabled: Boolean
}
type Invoice {

View File

@ -93,7 +93,10 @@ function sortHelper (a, b) {
}
}
export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = () => null, children }) {
const DEFAULT_BASE_LINE_ITEMS = {}
const DEFAULT_USE_REMOTE_LINE_ITEMS = () => null
export function FeeButtonProvider ({ baseLineItems = DEFAULT_BASE_LINE_ITEMS, useRemoteLineItems = DEFAULT_USE_REMOTE_LINE_ITEMS, children }) {
const [lineItems, setLineItems] = useState({})
const [disabled, setDisabled] = useState(false)
const { me } = useMe()

View File

@ -10,7 +10,10 @@ import { LIMIT } from '@/lib/cursor'
import ItemFull from './item-full'
import { useData } from './use-data'
export default function Items ({ ssrData, variables = {}, query, destructureData, rank, noMoreText, Footer, filter = () => true }) {
const DEFAULT_FILTER = () => true
const DEFAULT_VARIABLES = {}
export default function Items ({ ssrData, variables = DEFAULT_VARIABLES, query, destructureData, rank, noMoreText, Footer, filter = DEFAULT_FILTER }) {
const { data, fetchMore } = useQuery(query || SUB_ITEMS, { variables })
const Foooter = Footer || MoreFooter
const dat = useData(data, ssrData)

View File

@ -15,7 +15,11 @@ export function SubSelectInitial ({ sub }) {
}
}
export function useSubs ({ prependSubs = [], sub, filterSubs = () => true, appendSubs = [] }) {
const DEFAULT_PREPEND_SUBS = []
const DEFAULT_APPEND_SUBS = []
const DEFAULT_FILTER_SUBS = () => true
export function useSubs ({ prependSubs = DEFAULT_PREPEND_SUBS, sub, filterSubs = DEFAULT_FILTER_SUBS, appendSubs = DEFAULT_APPEND_SUBS }) {
const { data } = useQuery(SUBS, SSR
? {}
: {

View File

@ -17,7 +17,9 @@ export function debounce (fn, time) {
}
}
export default function useDebounceCallback (fn, time, deps = []) {
const DEFAULT_DEPS = []
export default function useDebounceCallback (fn, time, deps = DEFAULT_DEPS) {
const [args, setArgs] = useState([])
const memoFn = useCallback(fn, deps)
useNoInitialEffect(debounce(() => memoFn(...args), time), [memoFn, time, args])

View File

@ -4,7 +4,11 @@ export function getDbName (userId, name) {
return `app:storage:${userId ?? ''}${name ? `:${name}` : ''}`
}
function useIndexedDB ({ dbName, storeName, options = { keyPath: 'id', autoIncrement: true }, indices = [], version = 1 }) {
const DEFAULT_OPTIONS = { keyPath: 'id', autoIncrement: true }
const DEFAULT_INDICES = []
const DEFAULT_VERSION = 1
function useIndexedDB ({ dbName, storeName, options = DEFAULT_OPTIONS, indices = DEFAULT_INDICES, version = DEFAULT_VERSION }) {
const [db, setDb] = useState(null)
const [error, setError] = useState(null)
const [notSupported, setNotSupported] = useState(false)
@ -28,7 +32,7 @@ function useIndexedDB ({ dbName, storeName, options = { keyPath: 'id', autoIncre
} catch (error) {
handleError(error)
}
}, [storeName, handleError])
}, [storeName, handleError, operationQueue])
useEffect(() => {
let isMounted = true
@ -81,7 +85,7 @@ function useIndexedDB ({ dbName, storeName, options = { keyPath: 'id', autoIncre
db.close()
}
}
}, [dbName, storeName, version, indices, handleError, processQueue])
}, [dbName, storeName, version, indices, options, handleError, processQueue])
const queueOperation = useCallback((operation) => {
if (notSupported) {

View File

@ -140,7 +140,9 @@ function UserHidden ({ rank, Embellish }) {
)
}
export function ListUsers ({ users, rank, statComps = seperate(STAT_COMPONENTS, Seperator), Embellish, nymActionDropdown }) {
const DEFAULT_STAT_COMPONENTS = seperate(STAT_COMPONENTS, Seperator)
export function ListUsers ({ users, rank, statComps = DEFAULT_STAT_COMPONENTS, Embellish, nymActionDropdown }) {
return (
<div className={styles.grid}>
{users.map((user, i) => (
@ -155,7 +157,7 @@ export function ListUsers ({ users, rank, statComps = seperate(STAT_COMPONENTS,
export default function UserList ({ ssrData, query, variables, destructureData, rank, footer = true, nymActionDropdown, statCompsProp }) {
const { data, fetchMore } = useQuery(query, { variables })
const dat = useData(data, ssrData)
const [statComps, setStatComps] = useState(seperate(STAT_COMPONENTS, Seperator))
const [statComps, setStatComps] = useState(DEFAULT_STAT_COMPONENTS)
useEffect(() => {
// shift the stat we are sorting by to the front

View File

@ -2,7 +2,7 @@ import { useMutation, useQuery } from '@apollo/client'
import { useMe } from '../me'
import { useToast } from '../toast'
import useIndexedDB, { getDbName } from '../use-indexeddb'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { E_VAULT_KEY_EXISTS } from '@/lib/error'
import { CLEAR_VAULT, GET_VAULT_ENTRIES, UPDATE_VAULT_KEY } from '@/fragments/vault'
import { toHex } from '@/lib/hex'
@ -21,7 +21,8 @@ const useImperativeQuery = (query) => {
export function useVaultConfigurator () {
const { me } = useMe()
const toaster = useToast()
const { set, get, remove } = useIndexedDB({ dbName: getDbName(me?.id, 'vault'), storeName: 'vault' })
const idbConfig = useMemo(() => ({ dbName: getDbName(me?.id, 'vault'), storeName: 'vault' }), [me?.id])
const { set, get, remove } = useIndexedDB(idbConfig)
const [updateVaultKey] = useMutation(UPDATE_VAULT_KEY)
const getVaultEntries = useImperativeQuery(GET_VAULT_ENTRIES)
const [key, setKey] = useState(null)
@ -46,7 +47,7 @@ export function useVaultConfigurator () {
// toaster?.danger('error loading vault configuration ' + e.message)
}
})()
}, [me?.privates?.vaultKeyHash, keyHash, get, remove, toaster])
}, [me?.privates?.vaultKeyHash, keyHash, get, remove])
// clear vault: remove everything and reset the key
const [clearVault] = useMutation(CLEAR_VAULT, {

View File

@ -92,11 +92,11 @@ function getWalletLogDbName (userId) {
function useWalletLogDB () {
const { me } = useMe()
const { add, getPage, clear, error, notSupported } = useIndexedDB({
dbName: getWalletLogDbName(me?.id),
storeName: 'wallet_logs',
indices: INDICES
})
// memoize the idb config to avoid re-creating it on every render
const idbConfig = useMemo(() =>
({ dbName: getWalletLogDbName(me?.id), storeName: 'wallet_logs', indices: INDICES }), [me?.id])
const { add, getPage, clear, error, notSupported } = useIndexedDB(idbConfig)
return { add, getPage, clear, error, notSupported }
}

View File

@ -3,68 +3,12 @@ import { COMMENTS, COMMENTS_ITEM_EXT_FIELDS } from './comments'
import { ITEM_FIELDS, ITEM_FULL_FIELDS } from './items'
import { SUB_FULL_FIELDS } from './subs'
export const ME = gql`
{
me {
id
name
bioId
photoId
privates {
autoDropBolt11s
diagnostics
noReferralLinks
fiatCurrency
satsFilter
hideCowboyHat
hideFromTopUsers
hideGithub
hideNostr
hideTwitter
hideInvoiceDesc
hideIsContributor
hideWalletBalance
hideWelcomeBanner
imgproxyOnly
showImagesAndVideos
lastCheckedJobs
nostrCrossposting
noteAllDescendants
noteCowboyHat
noteDeposits
noteWithdrawals
noteEarning
noteForwardedSats
noteInvites
noteItemSats
noteJobIndicator
noteMentions
noteItemMentions
sats
tipDefault
tipRandom
tipRandomMin
tipRandomMax
tipPopover
turboTipping
zapUndos
upvotePopover
wildWestMode
withdrawMaxFeeDefault
lnAddr
autoWithdrawMaxFeePercent
autoWithdrawThreshold
disableFreebies
vaultKeyHash
}
optional {
isContributor
stacked
streak
githubId
nostrAuthPubkey
twitterId
}
const STREAK_FIELDS = gql`
fragment StreakFields on User {
optional {
streak
gunStreak
horseStreak
}
}
`
@ -104,6 +48,8 @@ ${STREAK_FIELDS}
upvotePopover
wildWestMode
disableFreebies
vaultKeyHash
walletsUpdatedAt
}
optional {
isContributor

View File

@ -106,86 +106,7 @@ mutation removeWallet($id: ID!) {
removeWallet(id: $id)
}
`
// XXX [WALLET] this needs to be updated if another server wallet is added
export const WALLET = gql`
query Wallet($id: ID!) {
wallet(id: $id) {
id
createdAt
priority
type
wallet {
__typename
... on WalletLightningAddress {
address
}
... on WalletLnd {
socket
macaroon
cert
}
... on WalletCln {
socket
rune
cert
}
... on WalletLnbits {
url
invoiceKey
}
... on WalletNwc {
nwcUrlRecv
}
... on WalletPhoenixd {
url
secondaryPassword
}
}
}
}
`
// XXX [WALLET] this needs to be updated if another server wallet is added
export const WALLET_BY_TYPE = gql`
query WalletByType($type: String!) {
walletByType(type: $type) {
id
createdAt
enabled
priority
type
wallet {
__typename
... on WalletLightningAddress {
address
}
... on WalletLnd {
socket
macaroon
cert
}
... on WalletCln {
socket
rune
cert
}
... on WalletLnbits {
url
invoiceKey
}
... on WalletNwc {
nwcUrlRecv
}
... on WalletPhoenixd {
url
secondaryPassword
}
}
}
}
`
export const WALLET_FIELDS = gql`
fragment WalletFields on Wallet {
id
@ -197,12 +118,57 @@ export const WALLET_FIELDS = gql`
key
value
}
wallet {
__typename
... on WalletLightningAddress {
address
}
... on WalletLnd {
socket
macaroon
cert
}
... on WalletCln {
socket
rune
cert
}
... on WalletLnbits {
url
invoiceKey
}
... on WalletNwc {
nwcUrlRecv
}
... on WalletPhoenixd {
url
secondaryPassword
}
}
}
`
export const WALLET = gql`
${WALLET_FIELDS}
query Wallet($id: ID!) {
wallet(id: $id) {
...WalletFields
}
}
`
// XXX [WALLET] this needs to be updated if another server wallet is added
export const WALLET_BY_TYPE = gql`
${WALLET_FIELDS}
query WalletByType($type: String!) {
walletByType(type: $type) {
...WalletFields
}
}
`
export const WALLETS = gql`
${WALLET_FIELDS}
query Wallets {
wallets {
...WalletFields

View File

@ -42,11 +42,11 @@ export async function formikValidate (validate, data) {
return result
}
export async function walletValidate (wallet, data) {
if (typeof wallet.def.fieldValidation === 'function') {
return await formikValidate(wallet.def.fieldValidation, data)
export async function walletValidate (walletDef, data) {
if (typeof walletDef.fieldValidation === 'function') {
return await formikValidate(walletDef.fieldValidation, data)
} else {
return await ssValidate(wallet.def.fieldValidation, data)
return await ssValidate(walletDef.fieldValidation, data)
}
}

88
package-lock.json generated
View File

@ -51,7 +51,7 @@
"mdast-util-gfm": "^3.0.0",
"mdast-util-to-string": "^4.0.0",
"micromark-extension-gfm": "^3.0.0",
"next": "^14.2.15",
"next": "^14.2.16",
"next-auth": "^4.24.8",
"next-plausible": "^3.12.2",
"next-seo": "^6.6.0",
@ -4124,9 +4124,9 @@
}
},
"node_modules/@next/env": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.15.tgz",
"integrity": "sha512-S1qaj25Wru2dUpcIZMjxeMVSwkt8BK4dmWHHiBuRstcIyOsMapqT4A4jSB6onvqeygkSSmOkyny9VVx8JIGamQ=="
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.16.tgz",
"integrity": "sha512-fLrX5TfJzHCbnZ9YUSnGW63tMV3L4nSfhgOQ0iCcX21Pt+VSTDuaLsSuL8J/2XAiVA5AnzvXDpf6pMs60QxOag=="
},
"node_modules/@next/eslint-plugin-next": {
"version": "14.2.15",
@ -4184,9 +4184,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.15.tgz",
"integrity": "sha512-Rvh7KU9hOUBnZ9TJ28n2Oa7dD9cvDBKua9IKx7cfQQ0GoYUwg9ig31O2oMwH3wm+pE3IkAQ67ZobPfEgurPZIA==",
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.16.tgz",
"integrity": "sha512-uFT34QojYkf0+nn6MEZ4gIWQ5aqGF11uIZ1HSxG+cSbj+Mg3+tYm8qXYd3dKN5jqKUm5rBVvf1PBRO/MeQ6rxw==",
"cpu": [
"arm64"
],
@ -4199,9 +4199,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.15.tgz",
"integrity": "sha512-5TGyjFcf8ampZP3e+FyCax5zFVHi+Oe7sZyaKOngsqyaNEpOgkKB3sqmymkZfowy3ufGA/tUgDPPxpQx931lHg==",
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.16.tgz",
"integrity": "sha512-mCecsFkYezem0QiZlg2bau3Xul77VxUD38b/auAjohMA22G9KTJneUYMv78vWoCCFkleFAhY1NIvbyjj1ncG9g==",
"cpu": [
"x64"
],
@ -4214,9 +4214,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.15.tgz",
"integrity": "sha512-3Bwv4oc08ONiQ3FiOLKT72Q+ndEMyLNsc/D3qnLMbtUYTQAmkx9E/JRu0DBpHxNddBmNT5hxz1mYBphJ3mfrrw==",
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.16.tgz",
"integrity": "sha512-yhkNA36+ECTC91KSyZcgWgKrYIyDnXZj8PqtJ+c2pMvj45xf7y/HrgI17hLdrcYamLfVt7pBaJUMxADtPaczHA==",
"cpu": [
"arm64"
],
@ -4229,9 +4229,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.15.tgz",
"integrity": "sha512-k5xf/tg1FBv/M4CMd8S+JL3uV9BnnRmoe7F+GWC3DxkTCD9aewFRH1s5rJ1zkzDa+Do4zyN8qD0N8c84Hu96FQ==",
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.16.tgz",
"integrity": "sha512-X2YSyu5RMys8R2lA0yLMCOCtqFOoLxrq2YbazFvcPOE4i/isubYjkh+JCpRmqYfEuCVltvlo+oGfj/b5T2pKUA==",
"cpu": [
"arm64"
],
@ -4244,9 +4244,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.15.tgz",
"integrity": "sha512-kE6q38hbrRbKEkkVn62reLXhThLRh6/TvgSP56GkFNhU22TbIrQDEMrO7j0IcQHcew2wfykq8lZyHFabz0oBrA==",
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.16.tgz",
"integrity": "sha512-9AGcX7VAkGbc5zTSa+bjQ757tkjr6C/pKS7OK8cX7QEiK6MHIIezBLcQ7gQqbDW2k5yaqba2aDtaBeyyZh1i6Q==",
"cpu": [
"x64"
],
@ -4259,9 +4259,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.15.tgz",
"integrity": "sha512-PZ5YE9ouy/IdO7QVJeIcyLn/Rc4ml9M2G4y3kCM9MNf1YKvFY4heg3pVa/jQbMro+tP6yc4G2o9LjAz1zxD7tQ==",
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.16.tgz",
"integrity": "sha512-Klgeagrdun4WWDaOizdbtIIm8khUDQJ/5cRzdpXHfkbY91LxBXeejL4kbZBrpR/nmgRrQvmz4l3OtttNVkz2Sg==",
"cpu": [
"x64"
],
@ -4274,9 +4274,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.15.tgz",
"integrity": "sha512-2raR16703kBvYEQD9HNLyb0/394yfqzmIeyp2nDzcPV4yPjqNUG3ohX6jX00WryXz6s1FXpVhsCo3i+g4RUX+g==",
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.16.tgz",
"integrity": "sha512-PwW8A1UC1Y0xIm83G3yFGPiOBftJK4zukTmk7DI1CebyMOoaVpd8aSy7K6GhobzhkjYvqS/QmzcfsWG2Dwizdg==",
"cpu": [
"arm64"
],
@ -4289,9 +4289,9 @@
}
},
"node_modules/@next/swc-win32-ia32-msvc": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.15.tgz",
"integrity": "sha512-fyTE8cklgkyR1p03kJa5zXEaZ9El+kDNM5A+66+8evQS5e/6v0Gk28LqA0Jet8gKSOyP+OTm/tJHzMlGdQerdQ==",
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.16.tgz",
"integrity": "sha512-jhPl3nN0oKEshJBNDAo0etGMzv0j3q3VYorTSFqH1o3rwv1MQRdor27u1zhkgsHPNeY1jxcgyx1ZsCkDD1IHgg==",
"cpu": [
"ia32"
],
@ -4304,9 +4304,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.15.tgz",
"integrity": "sha512-SzqGbsLsP9OwKNUG9nekShTwhj6JSB9ZLMWQ8g1gG6hdE5gQLncbnbymrwy2yVmH9nikSLYRYxYMFu78Ggp7/g==",
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.16.tgz",
"integrity": "sha512-OA7NtfxgirCjfqt+02BqxC3MIgM/JaGjw9tOe4fyZgPsqfseNiMPnCRP44Pfs+Gpo9zPN+SXaFsgP6vk8d571A==",
"cpu": [
"x64"
],
@ -15494,11 +15494,11 @@
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
},
"node_modules/next": {
"version": "14.2.15",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.15.tgz",
"integrity": "sha512-h9ctmOokpoDphRvMGnwOJAedT6zKhwqyZML9mDtspgf4Rh3Pn7UTYKqePNoDvhsWBAO5GoPNYshnAUGIazVGmw==",
"version": "14.2.16",
"resolved": "https://registry.npmjs.org/next/-/next-14.2.16.tgz",
"integrity": "sha512-LcO7WnFu6lYSvCzZoo1dB+IO0xXz5uEv52HF1IUN0IqVTUIZGHuuR10I5efiLadGt+4oZqTcNZyVVEem/TM5nA==",
"dependencies": {
"@next/env": "14.2.15",
"@next/env": "14.2.16",
"@swc/helpers": "0.5.5",
"busboy": "1.6.0",
"caniuse-lite": "^1.0.30001579",
@ -15513,15 +15513,15 @@
"node": ">=18.17.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "14.2.15",
"@next/swc-darwin-x64": "14.2.15",
"@next/swc-linux-arm64-gnu": "14.2.15",
"@next/swc-linux-arm64-musl": "14.2.15",
"@next/swc-linux-x64-gnu": "14.2.15",
"@next/swc-linux-x64-musl": "14.2.15",
"@next/swc-win32-arm64-msvc": "14.2.15",
"@next/swc-win32-ia32-msvc": "14.2.15",
"@next/swc-win32-x64-msvc": "14.2.15"
"@next/swc-darwin-arm64": "14.2.16",
"@next/swc-darwin-x64": "14.2.16",
"@next/swc-linux-arm64-gnu": "14.2.16",
"@next/swc-linux-arm64-musl": "14.2.16",
"@next/swc-linux-x64-gnu": "14.2.16",
"@next/swc-linux-x64-musl": "14.2.16",
"@next/swc-win32-arm64-msvc": "14.2.16",
"@next/swc-win32-ia32-msvc": "14.2.16",
"@next/swc-win32-x64-msvc": "14.2.16"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",

View File

@ -56,7 +56,7 @@
"mdast-util-gfm": "^3.0.0",
"mdast-util-to-string": "^4.0.0",
"micromark-extension-gfm": "^3.0.0",
"next": "^14.2.15",
"next": "^14.2.16",
"next-auth": "^4.24.8",
"next-plausible": "^3.12.2",
"next-seo": "^6.6.0",

View File

@ -11,16 +11,14 @@ ALTER TYPE "WalletType" ADD VALUE 'LNC';
ALTER TYPE "WalletType" ADD VALUE 'WEBLN';
-- AlterTable
ALTER TABLE "Wallet" ADD COLUMN "canReceive" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "canSend" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "vaultKeyHash" TEXT NOT NULL DEFAULT '';
ALTER TABLE "users" ADD COLUMN "vaultKeyHash" TEXT NOT NULL DEFAULT '',
ADD COLUMN "walletsUpdatedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "VaultEntry" (
"id" SERIAL NOT NULL,
"key" VARCHAR(64) NOT NULL,
"key" TEXT NOT NULL,
"iv" TEXT NOT NULL,
"value" TEXT NOT NULL,
"userId" INTEGER NOT NULL,
"walletId" INTEGER,
@ -30,17 +28,32 @@ CREATE TABLE "VaultEntry" (
CONSTRAINT "VaultEntry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "VaultEntry_userId_idx" ON "VaultEntry"("userId");
-- CreateIndex
CREATE INDEX "VaultEntry_walletId_idx" ON "VaultEntry"("walletId");
-- CreateIndex
CREATE UNIQUE INDEX "VaultEntry_userId_key_walletId_key" ON "VaultEntry"("userId", "key", "walletId");
CREATE UNIQUE INDEX "VaultEntry_userId_key_key" ON "VaultEntry"("userId", "key");
-- CreateIndex
CREATE INDEX "Wallet_priority_idx" ON "Wallet"("priority");
-- AddForeignKey
ALTER TABLE "VaultEntry" ADD CONSTRAINT "VaultEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "VaultEntry" ADD CONSTRAINT "VaultEntry_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE FUNCTION wallet_updated_at_trigger() RETURNS TRIGGER AS $$
BEGIN
UPDATE "users" SET "walletsUpdatedAt" = NOW() WHERE "id" = NEW."userId";
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER wallet_updated_at_trigger
AFTER INSERT OR UPDATE ON "Wallet"
FOR EACH ROW EXECUTE PROCEDURE wallet_updated_at_trigger();
CREATE TRIGGER vault_entry_updated_at_trigger
AFTER INSERT OR UPDATE ON "VaultEntry"
FOR EACH ROW EXECUTE PROCEDURE wallet_updated_at_trigger();

View File

@ -138,6 +138,7 @@ model User {
oneDayReferrals OneDayReferral[] @relation("OneDayReferral_referrer")
oneDayReferrees OneDayReferral[] @relation("OneDayReferral_referrees")
vaultKeyHash String @default("")
walletsUpdatedAt DateTime?
vaultEntries VaultEntry[] @relation("VaultEntries")
@@index([photoId])
@ -187,16 +188,14 @@ enum WalletType {
}
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?
enabled Boolean @default(true)
priority Int @default(0)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
canReceive Boolean @default(true)
canSend Boolean @default(false)
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
userId Int
label String?
enabled Boolean @default(true)
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
@ -218,11 +217,13 @@ model Wallet {
InvoiceForward InvoiceForward[]
@@index([userId])
@@index([priority])
}
model VaultEntry {
id Int @id @default(autoincrement())
key String @db.VarChar(64)
key String @db.Text
iv String @db.Text
value String @db.Text
userId Int
walletId Int?
@ -231,8 +232,7 @@ model VaultEntry {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
@@unique([userId, key, walletId])
@@index([userId])
@@unique([userId, key])
@@index([walletId])
}

View File

@ -74,24 +74,24 @@ function checkFields ({ fields, config }) {
return val
}
export function isConfigured (wallet) {
return isSendConfigured(wallet) || isReceiveConfigured(wallet)
export function isConfigured ({ def, config }) {
return isSendConfigured({ def, config }) || isReceiveConfigured({ def, config })
}
function isSendConfigured (wallet) {
const fields = wallet.def.fields.filter(isClientField)
return checkFields({ fields, config: wallet.config })
function isSendConfigured ({ def, config }) {
const fields = def.fields.filter(isClientField)
return checkFields({ fields, config })
}
function isReceiveConfigured (wallet) {
const fields = wallet.def.fields.filter(isServerField)
return checkFields({ fields, config: wallet.config })
function isReceiveConfigured ({ def, config }) {
const fields = def.fields.filter(isServerField)
return checkFields({ fields, config })
}
export function canSend (wallet) {
return !!wallet.def.sendPayment && isSendConfigured(wallet)
export function canSend ({ def, config }) {
return !!def.sendPayment && isSendConfigured({ def, config })
}
export function canReceive (wallet) {
return !wallet.def.clientOnly && isReceiveConfigured(wallet)
export function canReceive ({ def, config }) {
return !def.clientOnly && isReceiveConfigured({ def, config })
}

View File

@ -35,13 +35,12 @@ export function useWalletConfigurator (wallet) {
const _validate = useCallback(async (config, validateLightning = true) => {
const { serverWithShared, clientWithShared } = siftConfig(wallet.def.fields, config)
console.log('sifted', siftConfig(wallet.def.fields, config))
let clientConfig = clientWithShared
let serverConfig = serverWithShared
if (canSend(wallet)) {
let transformedConfig = await walletValidate(wallet, clientWithShared)
if (canSend({ def: wallet.def, config: clientConfig })) {
let transformedConfig = await walletValidate(wallet.def, clientWithShared)
if (transformedConfig) {
clientConfig = Object.assign(clientConfig, transformedConfig)
}
@ -51,29 +50,29 @@ export function useWalletConfigurator (wallet) {
clientConfig = Object.assign(clientConfig, transformedConfig)
}
}
}
if (canReceive(wallet)) {
const transformedConfig = await walletValidate(wallet, serverConfig)
} else if (canReceive({ def: wallet.def, config: serverConfig })) {
const transformedConfig = await walletValidate(wallet.def, serverConfig)
if (transformedConfig) {
serverConfig = Object.assign(serverConfig, transformedConfig)
}
} else {
throw new Error('configuration must be able to send or receive')
}
return { clientConfig, serverConfig }
}, [wallet])
const save = useCallback(async (newConfig, validateLightning = true) => {
const { clientConfig, serverConfig } = _validate(newConfig, validateLightning)
const { clientConfig, serverConfig } = await _validate(newConfig, validateLightning)
// if vault is active, encrypt and send to server regardless of wallet type
if (isActive) {
await _saveToServer(serverConfig, clientConfig, validateLightning)
} else {
if (canSend(wallet)) {
if (canSend({ def: wallet.def, config: clientConfig })) {
await _saveToLocal(clientConfig)
}
if (canReceive(wallet)) {
if (canReceive({ def: wallet.def, config: serverConfig })) {
await _saveToServer(serverConfig, clientConfig, validateLightning)
}
}
@ -84,18 +83,19 @@ export function useWalletConfigurator (wallet) {
}, [wallet.config?.id])
const _detachFromLocal = useCallback(async () => {
// if vault is not active and has a client config, delete from local storage
window.localStorage.removeItem(getStorageKey(wallet.def.name, me?.id))
}, [me?.id, wallet.def.name])
const detach = useCallback(async () => {
if (isActive) {
// if vault is active, detach all wallets from server
await _detachFromServer()
} else {
if (wallet.config.id) {
await _detachFromServer()
}
// if vault is not active and has a client config, delete from local storage
await _detachFromLocal()
}
}, [isActive, _detachFromServer, _detachFromLocal])

View File

@ -33,17 +33,18 @@ export function generateMutation (wallet) {
.filter(isServerField)
.map(f => `$${f.name}: String`)
.join(', ')
headerArgs += ', $settings: AutowithdrawSettings!, $validateLightning: Boolean'
headerArgs += ', $enabled: Boolean, $priority: Int, $vaultEntries: [VaultEntryInput!], $settings: AutowithdrawSettings!, $validateLightning: Boolean'
let inputArgs = 'id: $id, '
inputArgs += wallet.fields
.filter(isServerField)
.map(f => `${f.name}: $${f.name}`).join(', ')
inputArgs += ', settings: $settings, validateLightning: $validateLightning,'
inputArgs += ', enabled: $enabled, priority: $priority, vaultEntries: $vaultEntries, settings: $settings, validateLightning: $validateLightning'
return gql`mutation ${resolverName}(${headerArgs}) {
return gql`
${WALLET_FIELDS}
${resolverName}(${inputArgs}) {
mutation ${resolverName}(${headerArgs}) {
${resolverName}(${inputArgs}) {
...WalletFields
}
}`

View File

@ -1,6 +1,6 @@
import { useMe } from '@/components/me'
import { WALLETS } from '@/fragments/wallet'
import { LONG_POLL_INTERVAL, SSR } from '@/lib/constants'
import { SSR } from '@/lib/constants'
import { useQuery } from '@apollo/client'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { getStorageKey, getWalletByType, Status, walletPrioritySort, canSend } from './common'
@ -41,15 +41,17 @@ const walletDefsOnly = walletDefs.map(w => ({ def: w, config: {} }))
export function WalletsProvider ({ children }) {
const { decrypt } = useVault()
const { me } = useMe()
const { wallets: localWallets, reloadLocalWallets } = useLocalWallets()
// TODO: instead of polling, this should only be called when the vault key is updated
// or a denormalized field on the user 'vaultUpdatedAt' is changed
const { data } = useQuery(WALLETS, {
pollInterval: LONG_POLL_INTERVAL,
nextFetchPolicy: 'cache-and-network',
skip: SSR
})
const { data, refetch } = useQuery(WALLETS,
SSR ? {} : { nextFetchPolicy: 'cache-and-network' })
useEffect(() => {
if (me?.privates?.walletsUpdatedAt) {
refetch()
}
}, [me?.privates?.walletsUpdatedAt, me?.privates?.vaultKeyHash, refetch])
const wallets = useMemo(() => {
// form wallets into a list of { config, def }
@ -60,7 +62,9 @@ export function WalletsProvider ({ children }) {
config[key] = decrypt(value)
}
return { config, def }
// the specific wallet config on the server is stored in wallet.wallet
// on the client, it's stored in unnested
return { config: { ...config, ...w.wallet }, def }
}) ?? []
// merge wallets on name

View File

@ -17,6 +17,7 @@ import { parsePaymentRequest } from 'ln-service'
import { toPositiveNumber } from '@/lib/validate'
import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants'
import { withTimeout } from '@/lib/time'
import { canReceive } from './common'
export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln]
@ -25,7 +26,7 @@ const MAX_PENDING_INVOICES_PER_WALLET = 25
export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { models }) {
// get the wallets in order of priority
const wallets = await models.wallet.findMany({
where: { userId, enabled: true, canReceive: true },
where: { userId, enabled: true },
include: {
user: true
},
@ -42,11 +43,14 @@ export async function createInvoice (userId, { msats, description, descriptionHa
const w = walletDefs.find(w => w.walletType === wallet.def.walletType)
try {
const { walletType, walletField, createInvoice } = w
if (!canReceive({ def: w, config: wallet })) {
continue
}
const walletFull = await models.wallet.findFirst({
where: {
userId,
type: wallet.def.walletType
type: walletType
},
include: {
[walletField]: true