From b70dbeb6d6bcceed1e1f8527ea6d1a32bc3ecab3 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Mon, 14 Oct 2024 17:49:06 +0200 Subject: [PATCH 01/58] user vault and server side client wallets --- api/resolvers/index.js | 3 +- api/resolvers/vault.js | 148 +++++ api/resolvers/wallet.js | 168 ++++-- api/typeDefs/index.js | 3 +- api/typeDefs/user.js | 6 +- api/typeDefs/vault.js | 28 + api/typeDefs/wallet.js | 24 +- components/cancel-button.js | 2 +- components/device-sync.js | 264 +++++++++ components/form.js | 144 ++++- components/form.module.css | 18 +- components/modal.js | 13 +- components/nav/common.js | 3 + components/use-local-storage.js | 291 ++++++++++ components/use-vault.js | 426 ++++++++++++++ components/wallet-logger.js | 2 +- fragments/users.js | 74 ++- fragments/vault.js | 70 +++ fragments/wallet.js | 14 +- lib/error.js | 5 +- lib/hex.js | 20 + lib/task-queue.js | 54 ++ lib/validate.js | 20 + lib/wallet.js | 45 ++ pages/_app.js | 2 +- pages/settings/index.js | 2 + pages/settings/wallets/[wallet].js | 30 +- pages/settings/wallets/index.js | 7 +- .../20241011131443_vault/migration.sql | 28 + .../migration.sql | 63 +++ .../migration.sql | 25 + prisma/schema.prisma | 23 + svgs/clipboard-line.svg | 1 + svgs/qr-code-line.svg | 1 + svgs/qr-scan-line.svg | 1 + svgs/refresh-line.svg | 1 + wallets/README.md | 4 + wallets/blink/index.js | 6 +- wallets/cln/index.js | 9 +- wallets/index.js | 535 +++++++++--------- wallets/lightning-address/index.js | 9 +- wallets/lnbits/index.js | 9 +- wallets/lnc/index.js | 6 +- wallets/lnd/index.js | 9 +- wallets/nwc/index.js | 9 +- wallets/phoenixd/index.js | 8 +- wallets/server.js | 12 +- wallets/webln/client.js | 30 + wallets/webln/index.js | 32 +- 49 files changed, 2261 insertions(+), 446 deletions(-) create mode 100644 api/resolvers/vault.js create mode 100644 api/typeDefs/vault.js create mode 100644 components/device-sync.js create mode 100644 components/use-local-storage.js create mode 100644 components/use-vault.js create mode 100644 fragments/vault.js create mode 100644 lib/hex.js create mode 100644 lib/task-queue.js create mode 100644 prisma/migrations/20241011131443_vault/migration.sql create mode 100644 prisma/migrations/20241011131732_client_wallets/migration.sql create mode 100644 prisma/migrations/20241013200637_client_wallets_2/migration.sql create mode 100644 svgs/clipboard-line.svg create mode 100644 svgs/qr-code-line.svg create mode 100644 svgs/qr-scan-line.svg create mode 100644 svgs/refresh-line.svg diff --git a/api/resolvers/index.js b/api/resolvers/index.js index afeae521..eccfaf1d 100644 --- a/api/resolvers/index.js +++ b/api/resolvers/index.js @@ -19,6 +19,7 @@ import chainFee from './chainFee' import { GraphQLScalarType, Kind } from 'graphql' import { createIntScalar } from 'graphql-scalar' import paidAction from './paidAction' +import vault from './vault' const date = new GraphQLScalarType({ name: 'Date', @@ -55,4 +56,4 @@ const limit = createIntScalar({ export default [user, item, message, wallet, lnurl, notifications, invite, sub, upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee, - { JSONObject }, { Date: date }, { Limit: limit }, paidAction] + { JSONObject }, { Date: date }, { Limit: limit }, paidAction, vault] diff --git a/api/resolvers/vault.js b/api/resolvers/vault.js new file mode 100644 index 00000000..6c13eda2 --- /dev/null +++ b/api/resolvers/vault.js @@ -0,0 +1,148 @@ +import { E_VAULT_KEY_EXISTS, GqlAuthenticationError, GqlInputError } from '@/lib/error' + +export default { + VaultOwner: { + __resolveType: (obj) => obj.type + }, + Query: { + getVaultEntry: async (parent, { ownerId, ownerType, key }, { me, models }, info) => { + if (!me) throw new GqlAuthenticationError() + if (!key) throw new GqlInputError('must have key') + checkOwner(info, ownerType) + + const k = await models.vault.findUnique({ + where: { + userId_key_ownerId_ownerType: { + key, + userId: me.id, + ownerId: Number(ownerId), + ownerType + } + } + }) + return k + }, + getVaultEntries: async (parent, { ownerId, ownerType, keysFilter }, { me, models }, info) => { + if (!me) throw new GqlAuthenticationError() + checkOwner(info, ownerType) + + const entries = await models.vault.findMany({ + where: { + userId: me.id, + ownerId: Number(ownerId), + ownerType, + key: keysFilter?.length + ? { + in: keysFilter + } + : undefined + } + }) + return entries + } + }, + Mutation: { + setVaultEntry: async (parent, { ownerId, ownerType, key, value, skipIfSet }, { me, models }, info) => { + if (!me) throw new GqlAuthenticationError() + if (!key) throw new GqlInputError('must have key') + if (!value) throw new GqlInputError('must have value') + checkOwner(info, ownerType) + + if (skipIfSet) { + const existing = await models.vault.findUnique({ + where: { + userId_key_ownerId_ownerType: { + userId: me.id, + key, + ownerId: Number(ownerId), + ownerType + } + } + }) + if (existing) { + return false + } + } + await models.vault.upsert({ + where: { + userId_key_ownerId_ownerType: { + userId: me.id, + key, + ownerId: Number(ownerId), + ownerType + } + }, + update: { + value + }, + create: { + key, + value, + userId: me.id, + ownerId: Number(ownerId), + ownerType + } + }) + return true + }, + unsetVaultEntry: async (parent, { ownerId, ownerType, key }, { me, models }, info) => { + if (!me) throw new GqlAuthenticationError() + if (!key) throw new GqlInputError('must have key') + checkOwner(info, ownerType) + + await models.vault.deleteMany({ + where: { + userId: me.id, + key, + ownerId: Number(ownerId), + ownerType + } + }) + return true + }, + clearVault: async (parent, args, { me, models }) => { + if (!me) throw new GqlAuthenticationError() + + await models.user.update({ + where: { id: me.id }, + data: { vaultKeyHash: '' } + }) + await models.vault.deleteMany({ where: { userId: me.id } }) + return true + }, + setVaultKeyHash: async (parent, { hash }, { me, models }) => { + if (!me) throw new GqlAuthenticationError() + if (!hash) throw new GqlInputError('hash required') + + const { vaultKeyHash: oldKeyHash } = await models.user.findUnique({ where: { id: me.id } }) + if (oldKeyHash) { + if (oldKeyHash !== hash) { + throw new GqlInputError('vault key already set', E_VAULT_KEY_EXISTS) + } else { + return true + } + } else { + await models.user.update({ + where: { id: me.id }, + data: { vaultKeyHash: hash } + }) + } + return true + } + } +} + +/** + * Ensures the passed ownerType represent a valid type that extends VaultOwner in the graphql schema. + * Throws a GqlInputError otherwise + * @param {*} info the graphql resolve info + * @param {string} ownerType the ownerType to check + * @throws GqlInputError + */ +function checkOwner (info, ownerType) { + const gqltypeDef = info.schema.getType(ownerType) + const ownerInterfaces = gqltypeDef?.getInterfaces ? gqltypeDef.getInterfaces() : null + if (!ownerInterfaces?.some((iface) => iface.name === 'VaultOwner')) { + throw new GqlInputError('owner must implement VaultOwner interface but ' + ownerType + ' does not') + } +} diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index ee1677aa..a98bec6d 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -19,7 +19,7 @@ import assertApiKeyNotPermitted from './apiKey' import { bolt11Tags } from '@/lib/bolt11' import { finalizeHodlInvoice } from 'worker/wallet' import walletDefs from 'wallets/server' -import { generateResolverName, generateTypeDefName } from '@/lib/wallet' +import { generateResolverName, generateTypeDefName, isConfigured } from '@/lib/wallet' import { lnAddrOptions } from '@/lib/lnurl' import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error' import { getNodeSockets, getOurPubkey } from '../lnd' @@ -29,10 +29,18 @@ function injectResolvers (resolvers) { for (const w of walletDefs) { const resolverName = generateResolverName(w.walletField) console.log(resolverName) + resolvers.Mutation[resolverName] = async (parent, { settings, priorityOnly, canSend, canReceive, ...data }, { me, models }) => { + if (canReceive && !w.createInvoice) { + console.warn('Requested to upsert wallet as a receiver, but wallet does not support createInvoice. disabling') + canReceive = false + } - resolvers.Mutation[resolverName] = async (parent, { settings, priorityOnly, ...data }, { me, models }) => { - // allow transformation of the data on validation (this is optional ... won't do anything if not implemented) - if (!priorityOnly) { + if (!priorityOnly && canReceive) { + // check if the required fields are set + if (!isConfigured({ fields: w.fields, config: data, serverOnly: true })) { + throw new GqlInputError('missing required fields') + } + // allow transformation of the data on validation (this is optional ... won't do anything if not implemented) const validData = await walletValidate(w, { ...data, ...settings }) if (validData) { Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] }) @@ -41,9 +49,19 @@ function injectResolvers (resolvers) { } return await upsertWallet({ - wallet: { field: w.walletField, type: w.walletType }, - testCreateInvoice: (data) => w.testCreateInvoice(data, { me, models }) - }, { settings, data, priorityOnly }, { me, models }) + wallet: { + field: + w.walletField, + type: w.walletType + }, + testCreateInvoice: w.testCreateInvoice ? (data) => w.testCreateInvoice(data, { me, models }) : null + }, { + settings, + data, + priorityOnly, + canSend, + canReceive + }, { me, models }) } } console.groupEnd() @@ -158,14 +176,20 @@ const resolvers = { }) return wallet }, - wallets: async (parent, args, { me, models }) => { + wallets: async (parent, { includeReceivers = true, includeSenders = true, onlyEnabled = false }, { me, models }) => { if (!me) { throw new GqlAuthenticationError() } return await models.wallet.findMany({ where: { - userId: me.id + userId: me.id, + canReceive: includeReceivers, + canSend: includeSenders, + enabled: onlyEnabled !== undefined ? onlyEnabled : undefined + }, + orderBy: { + priority: 'desc' } }) }, @@ -627,13 +651,14 @@ export const addWalletLog = async ({ wallet, level, message }, { models }) => { } async function upsertWallet ( - { wallet, testCreateInvoice }, { settings, data, priorityOnly }, { me, models }) { - if (!me) { - throw new GqlAuthenticationError() - } + { wallet, testCreateInvoice }, + { settings, data, priorityOnly, canSend, canReceive }, + { me, models } +) { + if (!me) throw new GqlAuthenticationError() assertApiKeyNotPermitted({ me }) - if (testCreateInvoice && !priorityOnly) { + if (testCreateInvoice && !priorityOnly && canReceive) { try { await testCreateInvoice(data) } catch (err) { @@ -655,70 +680,103 @@ async function upsertWallet ( priority } = settings - const txs = [ - models.user.update({ - where: { id: me.id }, - data: { - autoWithdrawMaxFeePercent, - autoWithdrawThreshold, - autoWithdrawMaxFeeTotal - } - }) - ] + return await models.$transaction(async (tx) => { + if (canReceive) { + tx.user.update({ + where: { id: me.id }, + data: { + autoWithdrawMaxFeePercent, + autoWithdrawThreshold + } + }) + } - if (id) { - txs.push( - models.wallet.update({ + let updatedWallet + if (id) { + const existingWalletTypeRecord = canReceive + ? await tx[wallet.field].findUnique({ + where: { walletId: Number(id) } + }) + : undefined + + updatedWallet = tx.wallet.update({ where: { id: Number(id), userId: me.id }, data: { enabled, priority, - [wallet.field]: { - update: { - where: { walletId: Number(id) }, - data: walletData - } - } + 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 } + } + : {}) + }, + include: { + ...(canReceive && !priorityOnly ? { [wallet.field]: true } : {}) } }) - ) - } else { - txs.push( - models.wallet.create({ + } else { + updatedWallet = tx.wallet.create({ data: { enabled, priority, + canSend, + canReceive, userId: me.id, type: wallet.type, - [wallet.field]: { - create: walletData - } + // if send-only config or priority only, don't update the wallet type record + ...(canReceive && !priorityOnly + ? { + [wallet.field]: { + create: walletData + } + } + : {}) } }) - ) - } + } - txs.push( - models.walletLog.createMany({ - data: { + const logs = [] + if (canReceive) { + logs.push({ userId: me.id, wallet: wallet.type, - level: 'SUCCESS', + level: enabled ? 'SUCCESS' : 'INFO', message: id ? 'receive details updated' : 'wallet attached for receives' - } - }), - models.walletLog.create({ - data: { + }) + logs.push({ userId: me.id, wallet: wallet.type, level: enabled ? 'SUCCESS' : 'INFO', message: enabled ? 'receives enabled' : 'receives disabled' - } - }) - ) + }) + } - await models.$transaction(txs) - return true + 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' + }) + } + + tx.walletLog.createMany({ + data: logs + }) + + return updatedWallet + }) } export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, walletId = null }) { diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js index 29ed7dda..eb4e1e42 100644 --- a/api/typeDefs/index.js +++ b/api/typeDefs/index.js @@ -18,6 +18,7 @@ import admin from './admin' import blockHeight from './blockHeight' import chainFee from './chainFee' import paidAction from './paidAction' +import vault from './vault' const common = gql` type Query { @@ -38,4 +39,4 @@ const common = gql` ` export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite, - sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction] + sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction, vault] diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 4f987cd5..ca14e011 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -46,7 +46,7 @@ export default gql` disableFreebies: Boolean } - type User { + type User implements VaultOwner { id: ID! createdAt: Date! name: String @@ -182,7 +182,11 @@ export default gql` withdrawMaxFeeDefault: Int! autoWithdrawThreshold: Int autoWithdrawMaxFeePercent: Float +<<<<<<< HEAD autoWithdrawMaxFeeTotal: Int +======= + vaultKeyHash: String +>>>>>>> 002b1d19 (user vault and server side client wallets) } type UserOptional { diff --git a/api/typeDefs/vault.js b/api/typeDefs/vault.js new file mode 100644 index 00000000..0e8efd22 --- /dev/null +++ b/api/typeDefs/vault.js @@ -0,0 +1,28 @@ +import { gql } from 'graphql-tag' + +export default gql` + interface VaultOwner { + id: ID! + } + + type Vault { + id: ID! + key: String! + value: String! + createdAt: Date! + updatedAt: Date! + } + + extend type Query { + getVaultEntry(ownerId:ID!, ownerType:String!, key: String!): Vault + getVaultEntries(ownerId:ID!, ownerType:String!, keysFilter: [String]): [Vault!]! + } + + extend type Mutation { + setVaultEntry(ownerId:ID!, ownerType:String!, key: String!, value: String!, skipIfSet: Boolean): Boolean + unsetVaultEntry(ownerId:ID!, ownerType:String!, key: String!): Boolean + + clearVault: Boolean + setVaultKeyHash(hash: String!): String + } +` diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index cdd47a3c..a2137e27 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -1,8 +1,7 @@ import { gql } from 'graphql-tag' -import { fieldToGqlArg, generateResolverName, generateTypeDefName } from '@/lib/wallet' +import { fieldToGqlArg, fieldToGqlArgOptional, generateResolverName, generateTypeDefName, isServerField } from '@/lib/wallet' import walletDefs from 'wallets/server' -import { isServerField } from 'wallets' function injectTypeDefs (typeDefs) { const injected = [rawTypeDefs(), mutationTypeDefs()] @@ -14,12 +13,13 @@ function mutationTypeDefs () { const typeDefs = walletDefs.map((w) => { let args = 'id: ID, ' - args += w.fields + const serverFields = w.fields .filter(isServerField) - .map(fieldToGqlArg).join(', ') - args += ', settings: AutowithdrawSettings!, priorityOnly: Boolean' + .map(fieldToGqlArgOptional) + if (serverFields.length > 0) args += serverFields.join(', ') + ',' + args += 'settings: AutowithdrawSettings!, priorityOnly: Boolean, canSend: Boolean!, canReceive: Boolean!' const resolverName = generateResolverName(w.walletField) - const typeDef = `${resolverName}(${args}): Boolean` + const typeDef = `${resolverName}(${args}): Wallet` console.log(typeDef) return typeDef }) @@ -33,11 +33,15 @@ function rawTypeDefs () { console.group('injected GraphQL type defs:') const typeDefs = walletDefs.map((w) => { - const args = w.fields + let args = w.fields .filter(isServerField) .map(fieldToGqlArg) .map(s => ' ' + s) .join('\n') + if (!args) { + // add a placeholder arg so the type is not empty + args = ' _empty: Boolean' + } const typeDefName = generateTypeDefName(w.walletType) const typeDef = `type ${typeDefName} {\n${args}\n}` console.log(typeDef) @@ -63,7 +67,7 @@ const typeDefs = ` numBolt11s: Int! connectAddress: String! walletHistory(cursor: String, inc: String): History - wallets: [Wallet!]! + wallets(includeReceivers: Boolean, includeSenders: Boolean, onlyEnabled: Boolean): [Wallet!]! wallet(id: ID!): Wallet walletByType(type: String!): Wallet walletLogs(type: String, from: String, to: String, cursor: String): WalletLog! @@ -79,13 +83,15 @@ const typeDefs = ` deleteWalletLogs(wallet: String): Boolean } - type Wallet { + type Wallet implements VaultOwner { id: ID! createdAt: Date! type: String! enabled: Boolean! priority: Int! wallet: WalletDetails! + canReceive: Boolean! + canSend: Boolean! } input AutowithdrawSettings { diff --git a/components/cancel-button.js b/components/cancel-button.js index 1f4a4cc3..e9848d28 100644 --- a/components/cancel-button.js +++ b/components/cancel-button.js @@ -4,6 +4,6 @@ import Button from 'react-bootstrap/Button' export default function CancelButton ({ onClick }) { const router = useRouter() return ( - + ) } diff --git a/components/device-sync.js b/components/device-sync.js new file mode 100644 index 00000000..73818aff --- /dev/null +++ b/components/device-sync.js @@ -0,0 +1,264 @@ +import { useCallback, useEffect, useState } from 'react' +import { useMe } from './me' +import { useShowModal } from './modal' +import useVault, { useVaultConfigurator, useVaultMigration } from './use-vault' +import { Button, InputGroup } from 'react-bootstrap' +import { Form, Input, PasswordInput, SubmitButton } from './form' +import bip39Words from '@/lib/bip39-words' +import Info from './info' +import CancelButton from './cancel-button' +import * as yup from 'yup' +import { deviceSyncSchema } from '@/lib/validate' +import RefreshIcon from '@/svgs/refresh-line.svg' + +export default function DeviceSync () { + const { me } = useMe() + const [value, setVaultKey, clearVault, disconnectVault] = useVaultConfigurator() + const showModal = useShowModal() + + const enabled = !!me?.privates?.vaultKeyHash + const connected = !!value?.key + + const migrate = useVaultMigration() + const [debugValue, setDebugValue, clearValue] = useVault(me, 'debug') + + const manage = useCallback(async () => { + if (enabled && connected) { + showModal((onClose) => ( +
+

Device sync is enabled!

+

+ Sensitive data (like wallet credentials) is now securely synced between all connected devices. +

+

+ Disconnect to prevent this device from syncing data or to reset your passphrase. +

+
+
+ + +
+
+
+ )) + } else { + showModal((onClose) => ( + + )) + } + }, [migrate, enabled, connected, value]) + + const reset = useCallback(async () => { + const schema = yup.object().shape({ + confirm: yup.string() + .oneOf(['yes'], 'you must confirm by typing "yes"') + .required('required') + }) + showModal((onClose) => ( +
+

Reset device sync

+

+ This will delete all encrypted data on the server and disconnect all devices. +

+

+ You will need to enter a new passphrase on this and all other devices to sync data again. +

+
{ + await clearVault() + onClose() + }} + > + +
+
+ + + continue + +
+
+
+
+ )) + }, []) + + const onConnect = useCallback(async (values, formik) => { + if (values.passphrase) { + try { + await setVaultKey(values.passphrase) + await migrate() + } catch (e) { + formik?.setErrors({ passphrase: e.message }) + throw e + } + } + }, [setVaultKey, migrate]) + + return ( + <> +
device sync
+
+
+ +
+ +

+ Device sync uses end-to-end encryption to securely synchronize your data across devices. +

+

+ Your sensitive data remains private and inaccessible to our servers while being synced across all your connected devices using only a passphrase. +

+
+
+ + + + {enabled && !connected && ( +
+
+ +
+ +

+ If you have lost your passphrase or wish to erase all encrypted data from the server, you can reset the device sync data and start over. +

+

+ This action cannot be undone. +

+
+
+ )} + + ) +} + +const generatePassphrase = (n = 12) => { + const rand = new Uint32Array(n) + window.crypto.getRandomValues(rand) + return Array.from(rand).map(i => bip39Words[i % bip39Words.length]).join(' ') +} + +function ConnectForm ({ onClose, onConnect, enabled }) { + const [passphrase, setPassphrase] = useState(!enabled ? generatePassphrase : '') + + useEffect(() => { + const scannedPassphrase = window.localStorage.getItem('qr:passphrase') + if (scannedPassphrase) { + setPassphrase(scannedPassphrase) + window.localStorage.removeItem('qr:passphrase') + } + }) + + const newPassphrase = useCallback(() => { + setPassphrase(() => generatePassphrase(12)) + }, []) + + return ( +
+

{!enabled ? 'Enable device sync' : 'Input your passphrase'}

+

+ {!enabled + ? 'Enable secure sync of sensitive data (like wallet credentials) between your devices. You’ll need to enter this passphrase on each device you want to connect.' + : 'Enter the passphrase from device sync to access your encrypted sensitive data (like wallet credentials) on the server.'} +

+
{ + try { + await onConnect(values, formik) + onClose() + } catch {} + }} + > + + + + ) + } + /> +

+ { + !enabled + ? 'This passphrase is stored only on your device and cannot be shown again.' + : 'If you have forgotten your passphrase, you can reset and start over.' + } +

+
+
+
+ + {enabled ? 'connect' : 'enable'} +
+
+
+ +
+ ) +} diff --git a/components/form.js b/components/form.js index 26a956af..9a26091e 100644 --- a/components/form.js +++ b/components/form.js @@ -33,6 +33,12 @@ import EyeClose from '@/svgs/eye-close-line.svg' import Info from './info' import { useMe } from './me' import classNames from 'classnames' +import Clipboard from '@/svgs/clipboard-line.svg' +import QrIcon from '@/svgs/qr-code-line.svg' +import QrScanIcon from '@/svgs/qr-scan-line.svg' +import { useShowModal } from './modal' +import QRCode from 'qrcode.react' +import { QrScanner } from '@yudiel/react-qr-scanner' export class SessionRequiredError extends Error { constructor () { @@ -69,31 +75,41 @@ export function SubmitButton ({ ) } -export function CopyInput (props) { +function CopyButton ({ value, icon, ...props }) { const toaster = useToast() const [copied, setCopied] = useState(false) - const handleClick = async () => { + const handleClick = useCallback(async () => { try { - await copy(props.placeholder) + await copy(value) toaster.success('copied') setCopied(true) setTimeout(() => setCopied(false), 1500) } catch (err) { toaster.danger('failed to copy') } + }, [toaster, value]) + + if (icon) { + return ( + + + + ) } + return ( + + ) +} + +export function CopyInput (props) { return ( {copied ? : 'copy'} - + } {...props} /> @@ -711,10 +727,11 @@ export function InputUserSuggest ({ ) } -export function Input ({ label, groupClassName, ...props }) { +export function Input ({ label, groupClassName, under, ...props }) { return ( + {under} ) } @@ -1070,24 +1087,121 @@ function PasswordHider ({ onClick, showPass }) { > {!showPass ? : } ) } -export function PasswordInput ({ newPass, ...props }) { +function QrPassword ({ value }) { + const showModal = useShowModal() + const toaster = useToast() + + const showQr = useCallback(() => { + showModal(close => ( +
+

You can import this passphrase into another device by scanning this QR code

+ +
+ )) + }, [toaster, value, showModal]) + + return ( + <> + + + + + ) +} + +function PasswordScanner ({ onDecode }) { + const showModal = useShowModal() + const toaster = useToast() + const ref = useRef(false) + + return ( + { + showModal(onClose => { + return ( + { + onDecode(decoded) + + // avoid accidentally calling onClose multiple times + if (ref?.current) return + ref.current = true + + onClose({ back: 1 }) + }} + onError={(error) => { + if (error instanceof DOMException) return + toaster.danger('qr scan error:', error.message || error.toString?.()) + onClose({ back: 1 }) + }} + /> + ) + }) + }} + > + + + ) +} + +export function PasswordInput ({ newPass, qr, copy, readOnly, append, ...props }) { const [showPass, setShowPass] = useState(false) + const [field] = useField(props) + + const Append = useMemo(() => { + return ( + <> + setShowPass(!showPass)} /> + {copy && ( + + )} + {qr && (readOnly + ? + : { + // Formik helpers don't seem to work in another modal. + // I assume it's because we unmount the Formik component + // when replace it with another modal. + window.localStorage.setItem('qr:passphrase', decoded) + }} + />)} + {append} + + ) + }, [showPass, copy, field?.value, qr, readOnly, append]) + + const maskedValue = !showPass && props.as === 'textarea' ? field?.value?.replace(/./g, '•') : field?.value return ( setShowPass(!showPass)} />} + readOnly={readOnly} + append={props.as === 'textarea' ? undefined : Append} + value={maskedValue} + under={props.as === 'textarea' + ? ( +
+ {Append} +
) + : undefined} /> ) } diff --git a/components/form.module.css b/components/form.module.css index 6fa0c699..5d3f8f53 100644 --- a/components/form.module.css +++ b/components/form.module.css @@ -2,6 +2,10 @@ border-top-left-radius: 0; } +textarea.passwordInput { + resize: none; +} + .markdownInput textarea { margin-top: -1px; font-size: 94%; @@ -69,4 +73,16 @@ 0% { opacity: 42%; } -} \ No newline at end of file +} + +div.qr { + display: grid; +} + +div.qr>svg { + justify-self: center; + width: 100%; + height: auto; + padding: 1rem; + background-color: white; +} diff --git a/components/modal.js b/components/modal.js index 96aff77a..f01ca8a1 100644 --- a/components/modal.js +++ b/components/modal.js @@ -45,13 +45,20 @@ export default function useModal () { }, [getCurrentContent, forceUpdate]) // this is called on every navigation due to below useEffect - const onClose = useCallback(() => { + const onClose = useCallback((options) => { + if (options?.back) { + for (let i = 0; i < options.back; i++) { + onBack() + } + return + } + while (modalStack.current.length) { getCurrentContent()?.options?.onClose?.() modalStack.current.pop() } forceUpdate() - }, []) + }, [onBack]) const router = useRouter() useEffect(() => { @@ -90,7 +97,7 @@ export default function useModal () { {overflow} } - {modalStack.current.length > 1 ?
: null} + {modalStack.current.length > 1 ?
: null}
X
diff --git a/components/nav/common.js b/components/nav/common.js index fa2a5617..d42bca06 100644 --- a/components/nav/common.js +++ b/components/nav/common.js @@ -25,6 +25,7 @@ import { useHasNewNotes } from '../use-has-new-notes' import { useWallets } from 'wallets' import SwitchAccountList, { useAccounts } from '@/components/account' import { useShowModal } from '@/components/modal' +import { unsetLocalKey as resetVaultKey } from '@/components/use-vault' export function Brand ({ className }) { return ( @@ -265,6 +266,7 @@ function LogoutObstacle ({ onClose }) { const { registration: swRegistration, togglePushSubscription } = useServiceWorker() const wallets = useWallets() const { multiAuthSignout } = useAccounts() + const { me } = useMe() return (
@@ -293,6 +295,7 @@ function LogoutObstacle ({ onClose }) { } await wallets.resetClient().catch(console.error) + await resetVaultKey(me?.id) await signOut({ callbackUrl: '/' }) }} diff --git a/components/use-local-storage.js b/components/use-local-storage.js new file mode 100644 index 00000000..fcf09aa2 --- /dev/null +++ b/components/use-local-storage.js @@ -0,0 +1,291 @@ +import { SSR } from '@/lib/constants' +import { useMe } from './me' +import { useEffect, useState } from 'react' +import createTaskQueue from '@/lib/task-queue' + +const VERSION = 1 + +/** + * A react hook to use the local storage + * It handles the lifecycle of the storage, opening and closing it as needed. + * + * @param {*} options + * @param {string} options.database - the database name + * @param {[string]} options.namespace - the namespace of the storage + * @returns {[object]} - the local storage + */ +export default function useLocalStorage ({ database = 'default', namespace = ['default'] }) { + const { me } = useMe() + if (!Array.isArray(namespace)) namespace = [namespace] + const joinedNamespace = namespace.join(':') + const [storage, setStorage] = useState(openLocalStorage({ database, userId: me?.id, namespace })) + + useEffect(() => { + const currentStorage = storage + const newStorage = openLocalStorage({ database, userId: me?.id, namespace }) + setStorage(newStorage) + if (currentStorage) currentStorage.close() + return () => { + newStorage.close() + } + }, [me, database, joinedNamespace]) + + return [storage] +} + +/** + * Open a local storage. + * This is an abstraction on top of IndexedDB or, when not available, an in-memory storage. + * A combination of userId, database and namespace is used to efficiently separate different storage units. + * Namespaces can be an array of strings, that will be internally joined to form a single namespace. + * + * @param {*} options + * @param {string} options.userId - the user that owns the storage (anon if not provided) + * @param {string} options.database - the database name (default if not provided) + * @param {[string]} options.namespace - the namespace of the storage (default if not provided) + * @returns {object} - the local storage + * @throws Error if the namespace is invalid + */ +export function openLocalStorage ({ userId, database = 'default', namespace = ['default'] }) { + if (!userId) userId = 'anon' + if (!Array.isArray(namespace)) namespace = [namespace] + if (SSR) return createMemBackend(userId, namespace) + + let backend = newIdxDBBackend(userId, database, namespace) + + if (!backend) { + console.warn('no local storage backend available, fallback to in memory storage') + backend = createMemBackend(userId, namespace) + } + return backend +} + +export async function listLocalStorages ({ userId, database }) { + if (SSR) return [] + return await listIdxDBBackendNamespaces(userId, database) +} + +/** + * In memory storage backend (volatile/dummy storage) + */ +function createMemBackend (userId, namespace) { + const joinedNamespace = userId + ':' + namespace.join(':') + let memory = window?.snMemStorage?.[joinedNamespace] + if (!memory) { + memory = {} + if (window) { + if (!window.snMemStorage) window.snMemStorage = {} + window.snMemStorage[joinedNamespace] = memory + } + } + return { + set: (key, value) => { memory[key] = value }, + get: (key) => memory[key], + unset: (key) => { delete memory[key] }, + clear: () => { Object.keys(memory).forEach(key => delete memory[key]) }, + list: () => Object.keys(memory), + close: () => { } + } +} + +/** + * Open an IndexedDB connection + * @param {*} userId + * @param {*} database + * @param {*} onupgradeneeded + * @param {*} queue + * @returns {object} - an open connection + * @throws Error if the connection cannot be opened + */ +async function openIdxDB (userId, database, onupgradeneeded, queue) { + const fullDbName = `${database}:${userId}` + // we keep a reference to every open indexed db connection + // to reuse them whenever possible + if (window && !window.snIdxDB) window.snIdxDB = {} + let openConnection = window?.snIdxDB?.[fullDbName] + + const close = () => { + const conn = openConnection + conn.ref-- + if (conn.ref === 0) { // close the connection for real if nothing is using it + if (window?.snIdxDB) delete window.snIdxDB[fullDbName] + queue.enqueue(() => { + conn.db.close() + }) + } + } + + // if for any reason the connection is outdated, we close it + if (openConnection && openConnection.version !== VERSION) { + close() + openConnection = undefined + } + // an open connections is not available, so we create a new one + if (!openConnection) { + openConnection = { + version: VERSION, + ref: 1, // we need a ref count to know when to close the connection for real + db: null, + close + } + openConnection.db = await new Promise((resolve, reject) => { + const request = window.indexedDB.open(fullDbName, VERSION) + request.onupgradeneeded = (event) => { + const db = event.target.result + if (onupgradeneeded) onupgradeneeded(db) + } + request.onsuccess = (event) => { + const db = event.target.result + if (!db?.transaction) reject(new Error('unsupported implementation')) + else resolve(db) + } + request.onerror = reject + }) + window.snIdxDB[fullDbName] = openConnection + } else { + // increase the reference count + openConnection.ref++ + } + return openConnection +} + +/** + * An IndexedDB based persistent storage + * @param {string} userId - the user that owns the storage + * @param {string} database - the database name + * @returns {object} - an indexedDB persistent storage + * @throws Error if the namespace is invalid + */ +function newIdxDBBackend (userId, database, namespace) { + if (!window.indexedDB) return undefined + if (!namespace) throw new Error('missing namespace') + if (!Array.isArray(namespace) || !namespace.length || namespace.find(n => !n || typeof n !== 'string')) throw new Error('invalid namespace. must be a non-empty array of strings') + if (namespace.find(n => n.includes(':'))) throw new Error('invalid namespace. must not contain ":"') + + namespace = namespace.join(':') + + const queue = createTaskQueue() + + let openConnection = null + + const initialize = async () => { + if (!openConnection) { + openConnection = await openIdxDB(userId, database, (db) => { + db.createObjectStore(database, { keyPath: ['namespace', 'key'] }) + }, queue) + } + } + + return { + set: async (key, value) => { + await queue.enqueue(async () => { + await initialize() + const tx = openConnection.db.transaction([database], 'readwrite') + const objectStore = tx.objectStore(database) + objectStore.put({ namespace, key, value }) + await new Promise((resolve, reject) => { + tx.oncomplete = resolve + tx.onerror = reject + }) + }) + }, + get: async (key) => { + return await queue.enqueue(async () => { + await initialize() + const tx = openConnection.db.transaction([database], 'readonly') + const objectStore = tx.objectStore(database) + const request = objectStore.get([namespace, key]) + return await new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result?.value) + request.onerror = reject + }) + }) + }, + unset: async (key) => { + await queue.enqueue(async () => { + await initialize() + const tx = openConnection.db.transaction([database], 'readwrite') + const objectStore = tx.objectStore(database) + objectStore.delete([namespace, key]) + await new Promise((resolve, reject) => { + tx.oncomplete = resolve + tx.onerror = reject + }) + }) + }, + clear: async () => { + await queue.enqueue(async () => { + await initialize() + const tx = openConnection.db.transaction([database], 'readwrite') + const objectStore = tx.objectStore(database) + objectStore.clear() + await new Promise((resolve, reject) => { + tx.oncomplete = resolve + tx.onerror = reject + }) + }) + }, + list: async () => { + return await queue.enqueue(async () => { + await initialize() + const tx = openConnection.db.transaction([database], 'readonly') + const objectStore = tx.objectStore(database) + const keys = [] + return await new Promise((resolve, reject) => { + const request = objectStore.openCursor() + request.onsuccess = (event) => { + const cursor = event.target.result + if (cursor) { + if (cursor.key[0] === namespace) { + keys.push(cursor.key[1]) // Push only the 'key' part of the composite key + } + cursor.continue() + } else { + resolve(keys) + } + } + request.onerror = reject + }) + }) + }, + close: async () => { + queue.enqueue(async () => { + if (openConnection) await openConnection.close() + }) + } + } +} + +/** + * List all the namespaces used in an IndexedDB database + * @param {*} userId - the user that owns the storage + * @param {*} database - the database name + * @returns {array} - an array of namespace names + */ +async function listIdxDBBackendNamespaces (userId, database) { + if (!window?.indexedDB) return [] + const queue = createTaskQueue() + const openConnection = await openIdxDB(userId, database, null, queue) + try { + const list = await queue.enqueue(async () => { + const objectStore = openConnection.db.transaction([database], 'readonly').objectStore(database) + const namespaces = new Set() + return await new Promise((resolve, reject) => { + const request = objectStore.openCursor() + request.onsuccess = (event) => { + const cursor = event.target.result + if (cursor) { + namespaces.add(cursor.key[0]) + cursor.continue() + } else { + resolve(Array.from(namespaces).map(n => n.split(':'))) + } + } + request.onerror = reject + }) + }) + return list + } finally { + openConnection.close() + } +} diff --git a/components/use-vault.js b/components/use-vault.js new file mode 100644 index 00000000..3383c786 --- /dev/null +++ b/components/use-vault.js @@ -0,0 +1,426 @@ +import { useCallback, useState, useEffect, useRef } from 'react' +import { useMe } from '@/components/me' +import { useMutation, useApolloClient } from '@apollo/client' +import { SET_ENTRY, UNSET_ENTRY, GET_ENTRY, CLEAR_VAULT, SET_VAULT_KEY_HASH } from '@/fragments/vault' +import { E_VAULT_KEY_EXISTS } from '@/lib/error' +import { useToast } from '@/components/toast' +import useLocalStorage, { openLocalStorage, listLocalStorages } from '@/components/use-local-storage' +import { toHex, fromHex } from '@/lib/hex' +import createTaskQueue from '@/lib/task-queue' + +/** + * A react hook to configure the vault for the current user + */ +export function useVaultConfigurator () { + const { me } = useMe() + const toaster = useToast() + const [setVaultKeyHash] = useMutation(SET_VAULT_KEY_HASH) + + const [vaultKey, innerSetVaultKey] = useState(null) + const [config, configError] = useConfig() + + useEffect(() => { + if (!me) return + if (configError) { + toaster.danger('error loading vault configuration ' + configError.message) + return + } + (async () => { + let localVaultKey = await config.get('key') + if (localVaultKey && (!me.privates.vaultKeyHash || localVaultKey?.hash !== me.privates.vaultKeyHash)) { + // If the hash stored in the server does not match the hash of the local key, + // we can tell that the key is outdated (reset by another device or other reasons) + // in this case we clear the local key and let the user re-enter the passphrase + console.log('vault key hash mismatch, clearing local key', localVaultKey, me.privates.vaultKeyHash) + localVaultKey = null + await config.unset('key') + } + innerSetVaultKey(localVaultKey) + })() + }, [me?.privates?.vaultKeyHash, config, configError]) + + // clear vault: remove everything and reset the key + const [clearVault] = useMutation(CLEAR_VAULT, { + onCompleted: async () => { + await config.unset('key') + innerSetVaultKey(null) + } + }) + + // initialize the vault and set a vault key + const setVaultKey = useCallback(async (passphrase) => { + const vaultKey = await deriveKey(me.id, passphrase) + await setVaultKeyHash({ + variables: { hash: vaultKey.hash }, + onError: (error) => { + const errorCode = error.graphQLErrors[0]?.extensions?.code + if (errorCode === E_VAULT_KEY_EXISTS) { + throw new Error('wrong passphrase') + } + toaster.danger(error.graphQLErrors[0].message) + } + }) + innerSetVaultKey(vaultKey) + await config.set('key', vaultKey) + }, [setVaultKeyHash]) + + // disconnect the user from the vault (will not clear or reset the passphrase, use clearVault for that) + const disconnectVault = useCallback(async () => { + await config.unset('key') + innerSetVaultKey(null) + }, [innerSetVaultKey, config]) + + return [vaultKey, setVaultKey, clearVault, disconnectVault] +} + +/** + * A react hook to migrate local vault storage to the synched vault + */ +export function useVaultMigration () { + const { me } = useMe() + const apollo = useApolloClient() + // migrate local storage to vault + const migrate = useCallback(async () => { + let migratedCount = 0 + const config = await openConfig(me?.id) + const vaultKey = await config.get('key') + if (!vaultKey) throw new Error('vault key not found') + // we collect all the storages used by the vault + const namespaces = await listLocalStorages({ userId: me?.id, database: 'vault', supportLegacy: true }) + for (const namespace of namespaces) { + // we open every one of them and copy the entries to the vault + const storage = await openLocalStorage({ userId: me?.id, database: 'vault', namespace, supportLegacy: true }) + const entryNames = await storage.list() + for (const entryName of entryNames) { + try { + const value = await storage.get(entryName) + if (!value) throw new Error('no value found in local storage') + // (we know the layout we use for vault entries) + const type = namespace[0] + const id = namespace[1] + if (!type || !id || isNaN(id)) throw new Error('unknown vault namespace layout') + // encrypt and store on the server + const encrypted = await encryptData(vaultKey.key, value) + const { data } = await apollo.mutate({ + mutation: SET_ENTRY, + variables: { + key: entryName, + value: encrypted, + skipIfSet: true, + ownerType: type, + ownerId: Number(id) + } + }) + if (data?.setVaultEntry) { + // clear local storage + await storage.unset(entryName) + migratedCount++ + console.log('migrated to vault:', entryName) + } else { + throw new Error('could not set vault entry') + } + } catch (e) { + console.error('failed migrate to vault:', entryName, e) + } + } + await storage.close() + } + return migratedCount + }, [me?.id]) + + return migrate +} + +/** + * A react hook to use the vault for a specific owner entity and key + * It will automatically handle the vault lifecycle and value updates + * @param {*} owner - the owner entity with id and type or __typename (must extend VaultOwner in the graphql schema) + * @param {*} key - the key to store and retrieve the value + * @param {*} defaultValue - the default value to return when no value is found + * + * @returns {Array} - An array containing: + * @returns {any} 0 - The current value stored in the vault. + * @returns {function(any): Promise} 1 - A function to set a new value in the vault. + * @returns {function({onlyFromLocalStorage?: boolean}): Promise} 2 - A function to clear the value in the vault. + * @returns {function(): Promise} 3 - A function to refresh the value from the vault. + */ +export default function useVault (owner, key, defaultValue) { + const { me } = useMe() + const toaster = useToast() + const apollo = useApolloClient() + + const [value, innerSetValue] = useState(undefined) + const vault = useRef(openVault(apollo, me, owner)) + + const setValue = useCallback(async (newValue) => { + innerSetValue(newValue) + return vault.current.set(key, newValue) + }, [key]) + + const clearValue = useCallback(async ({ onlyFromLocalStorage = false } = {}) => { + innerSetValue(defaultValue) + return vault.current.clear(key, { onlyFromLocalStorage }) + }, [key, defaultValue]) + + const refreshData = useCallback(async () => { + innerSetValue(await vault.current.get(key)) + }, [key]) + + useEffect(() => { + const currentVault = vault.current + const newVault = openVault(apollo, me, owner) + vault.current = newVault + if (currentVault)currentVault.close() + refreshData().catch(e => toaster.danger('failed to refresh vault data: ' + e.message)) + return () => { + newVault.close() + } + }, [me, owner, key]) + + return [value, setValue, clearValue, refreshData] +} + +/** + * Open the vault for the given user and owner entry + * @param {*} apollo - the apollo client + * @param {*} user - the user entry with id and privates.vaultKeyHash + * @param {*} owner - the owner entry with id and type or __typename (must extend VaultOwner in the graphql schema) + * + * @returns {Object} - An object containing: + * @returns {function(string, any): Promise} get - A function to get a value from the vault. + * @returns {function(string, any): Promise} set - A function to set a new value in the vault. + * @returns {function(string, {onlyFromLocalStorage?: boolean}): Promise} clear - A function to clear a value in the vault. + * @returns {function(): Promise} refresh - A function to refresh the value from the vault. + */ +export function openVault (apollo, user, owner) { + const userId = user?.id + const type = owner?.__typename || owner?.type + const id = owner?.id + + const localOnly = !userId + + let config = null + let localStore = null + const queue = createTaskQueue() + + const waitInitialization = async () => { + if (!config) { + config = await openConfig(userId) + } + if (!localStore) { + localStore = type && id ? await openLocalStorage({ userId, database: localOnly ? 'local-vault' : 'vault', namespace: [type, id] }) : null + } + } + + const getValue = async (key, defaultValue) => { + return await queue.enqueue(async () => { + await waitInitialization() + if (!localStore) return undefined + + if (localOnly) { + // local only: we fetch from local storage and return + return ((await localStore.get(key)) || defaultValue) + } + + const localVaultKey = await config.get('key') + if (!localVaultKey?.hash) { + // no vault key set: use local storage + return ((await localStore.get(key)) || defaultValue) + } + + if ((!user.privates.vaultKeyHash && localVaultKey?.hash) || (localVaultKey?.hash !== user.privates.vaultKeyHash)) { + // no or different vault setup on server: use unencrypted local storage + // and clear local key if it exists + console.log('Vault key hash mismatch, clearing local key', localVaultKey, user.privates.vaultKeyHash) + await config.unset('key') + return ((await localStore.get(key)) || defaultValue) + } + + // if vault key hash is set on the server and matches our local key, we try to fetch from the vault + { + const { data: queriedData, error: queriedError } = await apollo.query({ + query: GET_ENTRY, + variables: { key, ownerId: id, ownerType: type }, + nextFetchPolicy: 'no-cache', + fetchPolicy: 'no-cache' + }) + console.log(queriedData) + if (queriedError) throw queriedError + const encryptedVaultValue = queriedData?.getVaultEntry?.value + if (encryptedVaultValue) { + try { + const vaultValue = await decryptData(localVaultKey.key, encryptedVaultValue) + // console.log('decrypted value from vault:', storageKey, encrypted, decrypted) + // remove local storage value if it exists + await localStore.unset(key) + return vaultValue + } catch (e) { + console.error('cannot read vault data:', key, e) + } + } + } + + // fallback to local storage + return ((await localStore.get(key)) || defaultValue) + }) + } + + const setValue = async (key, newValue) => { + return await queue.enqueue(async () => { + await waitInitialization() + + if (!localStore) { + return + } + const vaultKey = await config.get('key') + + const useVault = vaultKey && vaultKey.hash === user.privates.vaultKeyHash + + if (useVault && !localOnly) { + const encryptedValue = await encryptData(vaultKey.key, newValue) + console.log('store encrypted value in vault:', key) + await apollo.mutate({ + mutation: SET_ENTRY, + variables: { key, value: encryptedValue, ownerId: id, ownerType: type } + }) + // clear local storage (we get rid of stored unencrypted data as soon as it can be stored on the vault) + await localStore.unset(key) + } else { + console.log('store value in local storage:', key) + // otherwise use local storage + await localStore.set(key, newValue) + } + }) + } + + const clearValue = async (key, { onlyFromLocalStorage } = {}) => { + return await queue.enqueue(async () => { + await waitInitialization() + if (!localStore) return + + const vaultKey = await config.get('key') + const useVault = vaultKey && vaultKey.hash === user.privates.vaultKeyHash + + if (!localOnly && useVault && !onlyFromLocalStorage) { + await apollo.mutate({ + mutation: UNSET_ENTRY, + variables: { key, ownerId: id, ownerType: type } + }) + } + // clear local storage + await localStore.unset(key) + }) + } + + const close = async () => { + return await queue.enqueue(async () => { + await config?.close() + await localStore?.close() + config = null + localStore = null + }) + } + + return { get: getValue, set: setValue, clear: clearValue, close } +} + +function useConfig () { + return useLocalStorage({ database: 'vault-config', namespace: ['settings'], supportLegacy: false }) +} + +async function openConfig (userId) { + return await openLocalStorage({ userId, database: 'vault-config', namespace: ['settings'] }) +} + +/** + * Derive a key to be used for the vault encryption + * @param {string | number} userId - the id of the user (used for salting) + * @param {string} passphrase - the passphrase to derive the key from + * @returns {Promise<{key: CryptoKey, hash: string, extractable: boolean}>} an un-extractable CryptoKey and its hash + */ +async function deriveKey (userId, passphrase) { + const enc = new TextEncoder() + + const keyMaterial = await window.crypto.subtle.importKey( + 'raw', + enc.encode(passphrase), + { name: 'PBKDF2' }, + false, + ['deriveKey'] + ) + + const key = await window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: enc.encode(`stacker${userId}`), + // 600,000 iterations is recommended by OWASP + // see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 + iterations: 600_000, + hash: 'SHA-256' + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ) + + const rawKey = await window.crypto.subtle.exportKey('raw', key) + const hash = toHex(await window.crypto.subtle.digest('SHA-256', rawKey)) + const unextractableKey = await window.crypto.subtle.importKey( + 'raw', + rawKey, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'] + ) + + return { + key: unextractableKey, + hash + } +} + +/** + * Encrypt data using AES-GCM + * @param {CryptoKey} sharedKey - the key to use for encryption + * @param {Object} data - the data to encrypt + * @returns {Promise} a string representing the encrypted data, can be passed to decryptData to get the original data back + */ +async function encryptData (sharedKey, data) { + // random IVs are _really_ important in GCM: reusing the IV once can lead to catastrophic failure + // see https://crypto.stackexchange.com/questions/26790/how-bad-it-is-using-the-same-iv-twice-with-aes-gcm + const iv = window.crypto.getRandomValues(new Uint8Array(12)) + const encoded = new TextEncoder().encode(JSON.stringify(data)) + const encrypted = await window.crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv + }, + sharedKey, + encoded + ) + return JSON.stringify({ + iv: toHex(iv.buffer), + data: toHex(encrypted) + }) +} + +/** + * Decrypt data using AES-GCM + * @param {CryptoKey} sharedKey - the key to use for decryption + * @param {string} encryptedData - the encrypted data as returned by encryptData + * @returns {Promise} the original unencrypted data + */ +async function decryptData (sharedKey, encryptedData) { + const { iv, data } = JSON.parse(encryptedData) + const decrypted = await window.crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: fromHex(iv) + }, + sharedKey, + fromHex(data) + ) + const decoded = new TextDecoder().decode(decrypted) + return JSON.parse(decoded) +} diff --git a/components/wallet-logger.js b/components/wallet-logger.js index 8855badb..d3df24e7 100644 --- a/components/wallet-logger.js +++ b/components/wallet-logger.js @@ -145,7 +145,7 @@ export function useWalletLogger (wallet, setLogs) { const log = useCallback(level => message => { if (!wallet) { - console.error('cannot log: no wallet set') + // console.error('cannot log: no wallet set') return } diff --git a/fragments/users.js b/fragments/users.js index 16a703ce..f42060d3 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -3,12 +3,68 @@ import { COMMENTS, COMMENTS_ITEM_EXT_FIELDS } from './comments' import { ITEM_FIELDS, ITEM_FULL_FIELDS } from './items' import { SUB_FULL_FIELDS } from './subs' -export const STREAK_FIELDS = gql` - fragment StreakFields on User { - optional { - streak - gunStreak - horseStreak +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 + } } } ` @@ -371,3 +427,9 @@ export const USER_STATS = gql` } } }` + +export const SET_VAULT_KEY_HASH = gql` + mutation setVaultKeyHash($hash: String!) { + setVaultKeyHash(hash: $hash) + } +` diff --git a/fragments/vault.js b/fragments/vault.js new file mode 100644 index 00000000..7bf7bab9 --- /dev/null +++ b/fragments/vault.js @@ -0,0 +1,70 @@ +import { gql } from '@apollo/client' + +export const VAULT_FIELDS = gql` + fragment VaultFields on Vault { + id + key + value + createdAt + updatedAt + } +` + +export const GET_ENTRY = gql` + ${VAULT_FIELDS} + query GetVaultEntry( + $ownerId: ID!, + $ownerType: String!, + $key: String! + ) { + getVaultEntry(ownerId: $ownerId, ownerType: $ownerType, key: $key) { + ...VaultFields + } + } +` + +export const GET_ENTRIES = gql` + ${VAULT_FIELDS} + query GetVaultEntries( + $ownerId: ID!, + $ownerType: String! + ) { + getVaultEntries(ownerId: $ownerId, ownerType: $ownerType) { + ...VaultFields + } + } +` + +export const SET_ENTRY = gql` + mutation SetVaultEntry( + $ownerId: ID!, + $ownerType: String!, + $key: String!, + $value: String!, + $skipIfSet: Boolean + ) { + setVaultEntry(ownerId: $ownerId, ownerType: $ownerType, key: $key, value: $value, skipIfSet: $skipIfSet) + } +` + +export const UNSET_ENTRY = gql` + mutation UnsetVaultEntry( + $ownerId: ID!, + $ownerType: String!, + $key: String! + ) { + unsetVaultEntry(ownerId: $ownerId, ownerType: $ownerType, key: $key) + } +` + +export const CLEAR_VAULT = gql` + mutation ClearVault { + clearVault + } +` + +export const SET_VAULT_KEY_HASH = gql` + mutation SetVaultKeyHash($hash: String!) { + setVaultKeyHash(hash: $hash) + } +` diff --git a/fragments/wallet.js b/fragments/wallet.js index 89feb7cd..9cead739 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -188,7 +188,19 @@ export const WALLET_BY_TYPE = gql` export const WALLETS = gql` query Wallets { - wallets { + wallets{ + id + priority + type, + canSend, + canReceive + } + } +` + +export const BEST_SEND_WALLETS = gql` + query SendWallets { + wallets (includeSenders: true, includeReceivers: false, onlyEnabled: true) { id priority type diff --git a/lib/error.js b/lib/error.js index fcbade2b..3ba45521 100644 --- a/lib/error.js +++ b/lib/error.js @@ -3,6 +3,7 @@ import { GraphQLError } from 'graphql' export const E_FORBIDDEN = 'E_FORBIDDEN' export const E_UNAUTHENTICATED = 'E_UNAUTHENTICATED' export const E_BAD_INPUT = 'E_BAD_INPUT' +export const E_VAULT_KEY_EXISTS = 'E_VAULT_KEY_EXISTS' export class GqlAuthorizationError extends GraphQLError { constructor (message) { @@ -17,7 +18,7 @@ export class GqlAuthenticationError extends GraphQLError { } export class GqlInputError extends GraphQLError { - constructor (message) { - super(message, { extensions: { code: E_BAD_INPUT } }) + constructor (message, code) { + super(message, { extensions: { code: code || E_BAD_INPUT } }) } } diff --git a/lib/hex.js b/lib/hex.js new file mode 100644 index 00000000..3671c5a4 --- /dev/null +++ b/lib/hex.js @@ -0,0 +1,20 @@ +/** + * Convert a buffer to a hex string + * @param {*} buffer - the buffer to convert + * @returns {string} - the hex string + */ +export function toHex (buffer) { + const byteArray = new Uint8Array(buffer) + const hexString = Array.from(byteArray, byte => byte.toString(16).padStart(2, '0')).join('') + return hexString +} + +/** + * Convert a hex string to a buffer + * @param {string} hex - the hex string to convert + * @returns {ArrayBuffer} - the buffer + */ +export function fromHex (hex) { + const byteArray = new Uint8Array(hex.match(/.{1,2}/g).map(byte => parseInt(byte, 16))) + return byteArray.buffer +} diff --git a/lib/task-queue.js b/lib/task-queue.js new file mode 100644 index 00000000..c8a98190 --- /dev/null +++ b/lib/task-queue.js @@ -0,0 +1,54 @@ +/** + * Create a queue to run tasks sequentially + * @returns {Object} - the queue + * @returns {function} enqueue - Function to add a task to the queue + * @returns {function} lock - Function to lock the queue + * @returns {function} wait - Function to wait for the queue to be empty + */ +export default function createTaskQueue () { + const queue = { + queue: Promise.resolve(), + /** + * Enqueue a task to be run sequentially + * @param {function} fn - The task function to be enqueued + * @returns {Promise} - A promise that resolves with the result of the task function + */ + enqueue (fn) { + return new Promise((resolve, reject) => { + queue.queue = queue.queue.then(async () => { + try { + resolve(await fn()) + } catch (e) { + reject(e) + } + }) + }) + }, + /** + * Lock the queue so that it can't move forward until unlocked + * @param {boolean} [wait=true] - Whether to wait for the lock to be acquired + * @returns {Promise} - A promise that resolves with the unlock function + */ + async lock (wait = true) { + let unlock + const lock = new Promise((resolve) => { unlock = resolve }) + const locking = new Promise((resolve) => { + queue.queue = queue.queue.then(() => { + resolve() + return lock + }) + }) + if (wait) await locking + return unlock + }, + /** + * Wait for the queue to be empty + * @returns {Promise} - A promise that resolves when the queue is empty + */ + async wait () { + return queue.queue + } + } + + return queue +} diff --git a/lib/validate.js b/lib/validate.js index f8fd24d9..7fbadd34 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -844,3 +844,23 @@ export const toNumber = (x, min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE } export const toPositiveNumber = (x) => toNumber(x, 0) + +export const deviceSyncSchema = object().shape({ + passphrase: string().required('required') + .test(async (value, context) => { + const words = value ? value.trim().split(/[\s]+/) : [] + for (const w of words) { + try { + await string().oneOf(bip39Words).validate(w) + } catch { + return context.createError({ message: `'${w}' is not a valid pairing phrase word` }) + } + } + + if (words.length < 12) { + return context.createError({ message: 'needs at least 12 words' }) + } + + return true + }) +}) diff --git a/lib/wallet.js b/lib/wallet.js index 953c1fb8..65ec2e5e 100644 --- a/lib/wallet.js +++ b/lib/wallet.js @@ -6,6 +6,11 @@ export function fieldToGqlArg (field) { return arg } +// same as fieldToGqlArg, but makes the field always optional +export function fieldToGqlArgOptional (field) { + return `${field.name}: String` +} + export function generateResolverName (walletField) { const capitalized = walletField[0].toUpperCase() + walletField.slice(1) return `upsert${capitalized}` @@ -15,3 +20,43 @@ export function generateTypeDefName (walletType) { const PascalCase = walletType.split('_').map(s => s[0].toUpperCase() + s.slice(1).toLowerCase()).join('') return `Wallet${PascalCase}` } + +export function isServerField (f) { + return f.serverOnly || !f.clientOnly +} + +export function isClientField (f) { + return f.clientOnly || !f.serverOnly +} + +/** + * Check if a wallet is configured based on its fields and config + * @param {*} param0 + * @param {*} param0.fields - the fields of the wallet + * @param {*} param0.config - the configuration of the wallet + * @param {*} param0.serverOnly - if true, only check server fields + * @param {*} param0.clientOnly - if true, only check client fields + * @returns + */ +export function isConfigured ({ fields, config, serverOnly = false, clientOnly = false }) { + if (!config || !fields) return false + + fields = fields.filter(f => { + if (clientOnly) return isClientField(f) + if (serverOnly) return isServerField(f) + return true + }) + + // a wallet is configured if all of its required fields are set + let val = fields.every(f => { + return f.optional ? true : !!config?.[f.name] + }) + + // however, a wallet is not configured if all fields are optional and none are set + // since that usually means that one of them is required + if (val && fields.length > 0) { + val = !(fields.every(f => f.optional) && fields.every(f => !config?.[f.name])) + } + + return val +} diff --git a/pages/_app.js b/pages/_app.js index fd498779..6bb9d10d 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -20,7 +20,7 @@ import { LoggerProvider } from '@/components/logger' import { ChainFeeProvider } from '@/components/chain-fee.js' import dynamic from 'next/dynamic' import { HasNewNotesProvider } from '@/components/use-has-new-notes' -import WebLnProvider from '@/wallets/webln' +import { WebLnProvider } from '@/wallets/webln/client' import { AccountProvider } from '@/components/account' const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false }) diff --git a/pages/settings/index.js b/pages/settings/index.js index af75fe2b..feb3fadd 100644 --- a/pages/settings/index.js +++ b/pages/settings/index.js @@ -31,6 +31,7 @@ import { OverlayTrigger, Tooltip } from 'react-bootstrap' import { useField } from 'formik' import styles from './settings.module.css' import { AuthBanner } from '@/components/banners' +import DeviceSync from '@/components/device-sync' export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true }) @@ -606,6 +607,7 @@ export default function Settings ({ ssrData }) {
saturday newsletter
{settings?.authMethods && } + diff --git a/pages/settings/wallets/[wallet].js b/pages/settings/wallets/[wallet].js index 0343893f..08f6203d 100644 --- a/pages/settings/wallets/[wallet].js +++ b/pages/settings/wallets/[wallet].js @@ -22,7 +22,7 @@ export default function WalletSettings () { const { wallet: name } = router.query const wallet = useWallet(name) - const initial = wallet.fields.reduce((acc, field) => { + const initial = wallet?.fields.reduce((acc, field) => { // We still need to run over all wallet fields via reduce // even though we use wallet.config as the initial value // since wallet.config is empty when wallet is not configured. @@ -30,27 +30,27 @@ export default function WalletSettings () { // 'enabled' and 'priority' which are not defined in wallet.fields. return { ...acc, - [field.name]: wallet.config?.[field.name] || '' + [field.name]: wallet?.config?.[field.name] || '' } - }, wallet.config) + }, wallet?.config) // check if wallet uses the form-level validation built into Formik or a Yup schema - const validateProps = typeof wallet.fieldValidation === 'function' - ? { validate: wallet.fieldValidation } - : { schema: wallet.fieldValidation } + const validateProps = typeof wallet?.fieldValidation === 'function' + ? { validate: wallet?.fieldValidation } + : { schema: wallet?.fieldValidation } return ( -

{wallet.card.title}

-
{wallet.card.subtitle}
- {wallet.canSend && wallet.hasConfig > 0 && } +

{wallet?.card?.title}

+
{wallet?.card?.subtitle}
+ {wallet?.canSend && wallet?.hasConfig > 0 && }
{ try { - const newConfig = !wallet.isConfigured + const newConfig = !wallet?.isConfigured // enable wallet if wallet was just configured if (newConfig) { @@ -67,13 +67,13 @@ export default function WalletSettings () { } }} > - - {wallet.walletType + {wallet && } + {wallet?.walletType ? : ( { try { - await wallet.delete() + await wallet?.delete() toaster.success('saved settings') router.push('/settings/wallets') } catch (err) { @@ -95,7 +95,7 @@ export default function WalletSettings () { />
- + {wallet && }
) diff --git a/pages/settings/wallets/index.js b/pages/settings/wallets/index.js index 05924690..880f0703 100644 --- a/pages/settings/wallets/index.js +++ b/pages/settings/wallets/index.js @@ -92,7 +92,12 @@ export default function Wallet ({ ssrData }) { return (
\ No newline at end of file diff --git a/svgs/qr-code-line.svg b/svgs/qr-code-line.svg new file mode 100644 index 00000000..7fbcea98 --- /dev/null +++ b/svgs/qr-code-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svgs/qr-scan-line.svg b/svgs/qr-scan-line.svg new file mode 100644 index 00000000..6b9de9a4 --- /dev/null +++ b/svgs/qr-scan-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/svgs/refresh-line.svg b/svgs/refresh-line.svg new file mode 100644 index 00000000..05cf9868 --- /dev/null +++ b/svgs/refresh-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wallets/README.md b/wallets/README.md index 5fc96020..71713516 100644 --- a/wallets/README.md +++ b/wallets/README.md @@ -57,6 +57,10 @@ This acts as an ID for this wallet on the client. It therefore must be unique ac Since `name` will also be used in [wallet logs](https://stacker.news/wallet/logs), you can specify a shorter name here which will be used in logs instead. +- `perDevice?: boolean` + +This is an optional value. Set this to true if your wallet needs to be configured per device and should thus not be synced across devices. + - `fields: WalletField[]` Wallet fields define what this wallet requires for configuration and thus are used to construct the forms like the one you can see at [/settings/wallets/lnbits](https://stacker.news/settings/walletslnbits). diff --git a/wallets/blink/index.js b/wallets/blink/index.js index 6cbc3ff8..10c97cfd 100644 --- a/wallets/blink/index.js +++ b/wallets/blink/index.js @@ -4,6 +4,10 @@ export const galoyBlinkUrl = 'https://api.blink.sv/graphql' export const galoyBlinkDashboardUrl = 'https://dashboard.blink.sv/' export const name = 'blink' +export const walletType = 'BLINK' +export const walletField = 'walletBlink' +export const fieldValidation = blinkSchema +export const clientOnly = true export const fields = [ { @@ -30,5 +34,3 @@ export const card = { subtitle: 'use [Blink](https://blink.sv/) for payments', badges: ['send only'] } - -export const fieldValidation = blinkSchema diff --git a/wallets/cln/index.js b/wallets/cln/index.js index 644b7748..ff4a1196 100644 --- a/wallets/cln/index.js +++ b/wallets/cln/index.js @@ -1,6 +1,9 @@ import { CLNAutowithdrawSchema } from '@/lib/validate' export const name = 'cln' +export const walletType = 'CLN' +export const walletField = 'walletCLN' +export const fieldValidation = CLNAutowithdrawSchema export const fields = [ { @@ -38,9 +41,3 @@ export const card = { subtitle: 'autowithdraw to your Core Lightning node via [CLNRest](https://docs.corelightning.org/docs/rest)', badges: ['receive only'] } - -export const fieldValidation = CLNAutowithdrawSchema - -export const walletType = 'CLN' - -export const walletField = 'walletCLN' diff --git a/wallets/index.js b/wallets/index.js index 459b04b2..4ced5302 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -1,17 +1,16 @@ -import { useCallback } from 'react' +import { useCallback, useState, useEffect, useRef, useMemo } from 'react' import { useMe } from '@/components/me' -import useClientConfig from '@/components/use-local-state' +import { openVault } from '@/components/use-vault' import { useWalletLogger } from '@/components/wallet-logger' -import { SSR } from '@/lib/constants' import { bolt11Tags } from '@/lib/bolt11' import walletDefs from 'wallets/client' import { gql, useApolloClient, useMutation, useQuery } from '@apollo/client' -import { REMOVE_WALLET, WALLET_BY_TYPE } from '@/fragments/wallet' +import { REMOVE_WALLET, WALLET_BY_TYPE, BEST_SEND_WALLETS } from '@/fragments/wallet' import { autowithdrawInitial } from '@/components/autowithdraw-shared' import { useShowModal } from '@/components/modal' import { useToast } from '../components/toast' -import { generateResolverName } from '@/lib/wallet' +import { generateResolverName, isConfigured, isClientField, isServerField } from '@/lib/wallet' import { walletValidate } from '@/lib/validate' export const Status = { @@ -27,100 +26,125 @@ export function useWallet (name) { const toaster = useToast() const [disableFreebies] = useMutation(gql`mutation { disableFreebies }`) - const wallet = name ? getWalletByName(name) : getEnabledWallet(me) - const { logger, deleteLogs } = useWalletLogger(wallet) + const { data: bestSendWalletList } = useQuery(BEST_SEND_WALLETS) - const [config, saveConfig, clearConfig] = useConfig(wallet) - const hasConfig = wallet?.fields.length > 0 - const _isConfigured = isConfigured({ ...wallet, config }) + if (!name) { + // find best wallet in list + const bestWalletDef = bestSendWalletList?.wallets + // .filter(w => w.enabled && w.canSend)// filtered by the server + // .sort((a, b) => b.priority - a.priority) // already priority sorted by the server + .map(w => getWalletByType(w.type)) + .filter(w => !w.isAvailable || w.isAvailable())[0] + name = bestWalletDef?.name + } - const enablePayments = useCallback(() => { - enableWallet(name, me) - logger.ok('payments enabled') - disableFreebies().catch(console.error) - }, [name, me, logger]) + const walletDef = getWalletByName(name) - const disablePayments = useCallback(() => { - disableWallet(name, me) - logger.info('payments disabled') - }, [name, me, logger]) + const { logger, deleteLogs } = useWalletLogger(walletDef) + const [config, saveConfig, clearConfig] = useConfig(walletDef) const status = config?.enabled ? Status.Enabled : Status.Initialized const enabled = status === Status.Enabled const priority = config?.priority + const hasConfig = walletDef?.fields?.length > 0 + + const _isConfigured = useCallback(() => { + return isConfigured({ ...walletDef, config }) + }, [walletDef, config]) + + const enablePayments = useCallback((updatedConfig) => { + saveConfig({ ...(updatedConfig || config), enabled: true }, { skipTests: true }) + logger.ok('payments enabled') + disableFreebies().catch(console.error) + }, [config]) + + const disablePayments = useCallback((updatedConfig) => { + saveConfig({ ...(updatedConfig || config), enabled: false }, { skipTests: true }) + logger.info('payments disabled') + }, [config]) const sendPayment = useCallback(async (bolt11) => { const hash = bolt11Tags(bolt11).payment_hash logger.info('sending payment:', `payment_hash=${hash}`) try { - const preimage = await wallet.sendPayment(bolt11, config, { me, logger, status, showModal }) + const preimage = await walletDef.sendPayment(bolt11, config, { me, logger, status, showModal }) logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`) } catch (err) { const message = err.message || err.toString?.() logger.error('payment failed:', `payment_hash=${hash}`, message) throw err } - }, [me, wallet, config, logger, status]) + }, [me, walletDef, config, status]) const setPriority = useCallback(async (priority) => { - if (_isConfigured && priority !== config.priority) { + if (_isConfigured() && priority !== config.priority) { try { - await saveConfig({ ...config, priority }, { logger, priorityOnly: true }) + await saveConfig({ ...config, priority }, { logger, skipTests: true }) } catch (err) { - toaster.danger(`failed to change priority of ${wallet.name} wallet: ${err.message}`) + toaster.danger(`failed to change priority of ${walletDef.name} wallet: ${err.message}`) } } - }, [wallet, config, toaster]) + }, [walletDef, config]) const save = useCallback(async (newConfig) => { await saveConfig(newConfig, { logger }) - }, [saveConfig, me, logger]) + }, [saveConfig, me]) // delete is a reserved keyword const delete_ = useCallback(async (options) => { try { + logger.ok('wallet detached for payments') await clearConfig({ logger, ...options }) } catch (err) { const message = err.message || err.toString?.() logger.error(message) throw err } - }, [clearConfig, logger, disablePayments]) + }, [clearConfig]) const deleteLogs_ = useCallback(async (options) => { // first argument is to override the wallet return await deleteLogs(options) }, [deleteLogs]) - if (!wallet) return null + const wallet = useMemo(() => { + if (!walletDef) return {} + const available = (!walletDef.isAvailable || walletDef.isAvailable()) + const wallet = { + ...walletDef + } + wallet.isConfigured = _isConfigured() + wallet.enablePayments = enablePayments + wallet.disablePayments = disablePayments + wallet.canSend = config.canSend && available + wallet.canReceive = config.canReceive + wallet.config = config + wallet.save = save + wallet.delete = delete_ + wallet.deleteLogs = deleteLogs_ + wallet.setPriority = setPriority + wallet.hasConfig = hasConfig + wallet.status = status + wallet.enabled = enabled && available + wallet.priority = priority + wallet.logger = logger + wallet.sendPayment = sendPayment + wallet.def = walletDef + logger.ok(walletDef.isConfigured ? 'payment details updated' : 'wallet attached for payments') + return wallet + }, [walletDef, config, status, enabled, priority, logger, enablePayments, disablePayments, save, delete_, deleteLogs_, setPriority, hasConfig]) - // Assign everything to wallet object so every function that is passed this wallet object in this - // `useWallet` hook has access to all others via the reference to it. - // Essentially, you can now use functions like `enablePayments` _inside_ of functions that are - // called by `useWallet` even before enablePayments is defined and not only in functions - // that use the return value of `useWallet`. - wallet.isConfigured = _isConfigured - wallet.enablePayments = enablePayments - wallet.disablePayments = disablePayments - wallet.canSend = !!wallet.sendPayment - wallet.canReceive = !!wallet.createInvoice - wallet.config = config - wallet.save = save - wallet.delete = delete_ - wallet.deleteLogs = deleteLogs_ - wallet.setPriority = setPriority - wallet.hasConfig = hasConfig - wallet.status = status - wallet.enabled = enabled - wallet.priority = priority - wallet.logger = logger + useEffect(() => { + if (wallet.enabled && wallet.canSend) { + disableFreebies().catch(console.error) + logger.ok('payments enabled') + } + }, [wallet]) - // can't assign sendPayment to wallet object because it already exists - // as an imported function and thus can't be overwritten - return { ...wallet, sendPayment } + return wallet } -function extractConfig (fields, config, client) { +function extractConfig (fields, config, client, includeMeta = true) { return Object.entries(config).reduce((acc, [key, value]) => { const field = fields.find(({ name }) => name === key) @@ -129,7 +153,7 @@ function extractConfig (fields, config, client) { if (client && key === 'id') return acc // field might not exist because config.enabled doesn't map to a wallet field - if (!field || (client ? isClientField(field) : isServerField(field))) { + if ((!field && includeMeta) || (field && (client ? isClientField(field) : isServerField(field)))) { return { ...acc, [key]: value @@ -140,205 +164,217 @@ function extractConfig (fields, config, client) { }, {}) } -export function isServerField (f) { - return f.serverOnly || !f.clientOnly -} - -export function isClientField (f) { - return f.clientOnly || !f.serverOnly -} - function extractClientConfig (fields, config) { - return extractConfig(fields, config, true) + return extractConfig(fields, config, true, false) } function extractServerConfig (fields, config) { - return extractConfig(fields, config, false) + return extractConfig(fields, config, false, true) } -function useConfig (wallet) { +function useConfig (walletDef) { + const client = useApolloClient() const { me } = useMe() + const toaster = useToast() + const autowithdrawSettings = autowithdrawInitial({ me }) + const clientVault = useRef(null) - const storageKey = getStorageKey(wallet?.name, me) - const [clientConfig, setClientConfig, clearClientConfig] = useClientConfig(storageKey, {}) + const [config, innerSetConfig] = useState({}) + const [currentWallet, innerSetCurrentWallet] = useState(null) - const [serverConfig, setServerConfig, clearServerConfig] = useServerConfig(wallet) + const canSend = !!walletDef?.sendPayment + const canReceive = !walletDef?.clientOnly - const hasClientConfig = !!wallet?.sendPayment - const hasServerConfig = !!wallet?.walletType + const refreshConfig = useCallback(async () => { + if (walletDef) { + let newConfig = {} + newConfig = { + ...autowithdrawSettings + } - let config = {} - if (hasClientConfig) config = clientConfig - if (hasServerConfig) { - const { enabled, priority } = config || {} - config = { - ...config, - ...serverConfig + // fetch server config + const serverConfig = await client.query({ + query: WALLET_BY_TYPE, + variables: { type: walletDef.walletType }, + fetchPolicy: 'no-cache' + }) + + if (serverConfig?.data?.walletByType) { + newConfig = { + ...newConfig, + id: serverConfig.data.walletByType.id, + priority: serverConfig.data.walletByType.priority, + enabled: serverConfig.data.walletByType.enabled + } + if (serverConfig.data.walletByType.wallet) { + newConfig = { + ...newConfig, + ...serverConfig.data.walletByType.wallet + } + } + } + + // fetch client config + let clientConfig = {} + if (serverConfig?.data?.walletByType) { + if (clientVault.current) { + clientVault.current.close() + } + const newClientVault = openVault(client, me, serverConfig.data.walletByType) + clientVault.current = newClientVault + clientConfig = await newClientVault.get(walletDef.name, {}) + if (clientConfig) { + for (const [key, value] of Object.entries(clientConfig)) { + if (newConfig[key] === undefined) { + newConfig[key] = value + } else { + console.warn('Client config key', key, 'already exists in server config') + } + } + } + } + + if (newConfig.canSend == null) { + newConfig.canSend = canSend && isConfigured({ fields: walletDef.fields, config: newConfig, clientOnly: true }) + } + + if (newConfig.canReceive == null) { + newConfig.canReceive = canReceive && isConfigured({ fields: walletDef.fields, config: newConfig, serverOnly: true }) + } + + // console.log('Client config', clientConfig) + // console.log('Server config', serverConfig) + // console.log('Merged config', newConfig) + + // set merged config + innerSetConfig(newConfig) + + // set wallet ref + innerSetCurrentWallet(serverConfig.data.walletByType) } - // wallet is enabled if enabled is set in client or server config - config.enabled ||= enabled - // priority might only be set on client or server - // ie. if send+recv is available but only one is configured - config.priority ||= priority - } + }, [walletDef, me]) - const saveConfig = useCallback(async (newConfig, { logger, priorityOnly }) => { - // NOTE: - // verifying the client/server configuration before saving it - // prevents unsetting just one configuration if both are set. - // This means there is no way of unsetting just one configuration - // since 'detach' detaches both. - // Not optimal UX but the trade-off is saving invalid configurations - // and maybe it's not that big of an issue. - if (hasClientConfig) { - let newClientConfig = extractClientConfig(wallet.fields, newConfig) + useEffect(() => { + refreshConfig() + }, [walletDef, me]) - let valid = true + const saveConfig = useCallback(async (newConfig, { logger, skipTests }) => { + const priorityOnly = skipTests + const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, priority, enabled } = newConfig + try { + // gather configs + + let newClientConfig = extractClientConfig(walletDef.fields, newConfig) try { - const transformedConfig = await walletValidate(wallet, newClientConfig) + const transformedConfig = await walletValidate(walletDef, newClientConfig) if (transformedConfig) { newClientConfig = Object.assign(newClientConfig, transformedConfig) } - // these are stored on the server - delete newClientConfig.autoWithdrawMaxFeePercent - delete newClientConfig.autoWithdrawThreshold - delete newClientConfig.autoWithdrawMaxFeeTotal - } catch { - valid = false + } catch (e) { + newClientConfig = {} } - if (valid) { - if (priorityOnly) { - setClientConfig(newClientConfig) - } else { - try { - // XXX: testSendPayment can return a new config (e.g. lnc) - const newerConfig = await wallet.testSendPayment?.(newConfig, { me, logger }) - if (newerConfig) { - newClientConfig = Object.assign(newClientConfig, newerConfig) - } - } catch (err) { - logger.error(err.message) - throw err - } - - setClientConfig(newClientConfig) - logger.ok(wallet.isConfigured ? 'payment details updated' : 'wallet attached for payments') - if (newConfig.enabled) wallet.enablePayments() - else wallet.disablePayments() - } - } - } - - if (hasServerConfig) { - let newServerConfig = extractServerConfig(wallet.fields, newConfig) - - let valid = true + let newServerConfig = extractServerConfig(walletDef.fields, newConfig) try { - const transformedConfig = await walletValidate(wallet, newServerConfig) + const transformedConfig = await walletValidate(walletDef, newServerConfig) if (transformedConfig) { newServerConfig = Object.assign(newServerConfig, transformedConfig) } - } catch { - valid = false + } catch (e) { + newServerConfig = {} } - if (valid) await setServerConfig(newServerConfig, { priorityOnly }) - } - }, [hasClientConfig, hasServerConfig, setClientConfig, setServerConfig, wallet]) + // check if it misses send or receive configs + const isReadyToSend = canSend && isConfigured({ fields: walletDef.fields, config: newConfig, clientOnly: true }) + const isReadyToReceive = canReceive && isConfigured({ fields: walletDef.fields, config: newConfig, serverOnly: true }) - const clearConfig = useCallback(async ({ logger, clientOnly }) => { - if (hasClientConfig) { - clearClientConfig() - wallet.disablePayments() - logger.ok('wallet detached for payments') - } - if (hasServerConfig && !clientOnly) await clearServerConfig() - }, [hasClientConfig, hasServerConfig, clearClientConfig, clearServerConfig, wallet]) + // console.log('New client config', newClientConfig) + // console.log('New server config', newServerConfig) + // console.log('Sender', isReadyToSend, 'Receiver', isReadyToReceive, 'enabled', enabled) - return [config, saveConfig, clearConfig] -} - -function isConfigured ({ fields, config }) { - if (!config || !fields) return false - - // a wallet is configured if all of its required fields are set - let val = fields.every(f => { - return f.optional ? true : !!config?.[f.name] - }) - - // however, a wallet is not configured if all fields are optional and none are set - // since that usually means that one of them is required - if (val && fields.length > 0) { - val = !(fields.every(f => f.optional) && fields.every(f => !config?.[f.name])) - } - - return val -} - -function useServerConfig (wallet) { - const client = useApolloClient() - const { me } = useMe() - - const { data, refetch: refetchConfig } = useQuery(WALLET_BY_TYPE, { variables: { type: wallet?.walletType }, skip: !wallet?.walletType }) - - const walletId = data?.walletByType?.id - const serverConfig = { - id: walletId, - priority: data?.walletByType?.priority, - enabled: data?.walletByType?.enabled, - ...data?.walletByType?.wallet - } - delete serverConfig.__typename - - const autowithdrawSettings = autowithdrawInitial({ me }) - const config = { ...serverConfig, ...autowithdrawSettings } - - const saveConfig = useCallback(async ({ - autoWithdrawThreshold, - autoWithdrawMaxFeePercent, - autoWithdrawMaxFeeTotal, - priority, - enabled, - ...config - }, { priorityOnly }) => { - try { - const mutation = generateMutation(wallet) - return await client.mutate({ - mutation, - variables: { - ...config, - id: walletId, - settings: { - autoWithdrawThreshold: Number(autoWithdrawThreshold), - autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent), - autoWithdrawMaxFeeTotal: Number(autoWithdrawMaxFeeTotal), - priority, - enabled - }, - priorityOnly + // client test + if (!skipTests && isReadyToSend) { + try { + // XXX: testSendPayment can return a new config (e.g. lnc) + const newerConfig = await walletDef.testSendPayment?.(newClientConfig, { me, logger }) + if (newerConfig) { + newClientConfig = Object.assign(newClientConfig, newerConfig) + } + } catch (err) { + logger.error(err.message) + throw err } + } + + // set server config (will create wallet if it doesn't exist) (it is also testing receive config) + const mutation = generateMutation(walletDef) + const variables = { + ...newServerConfig, + id: currentWallet?.id, + settings: { + autoWithdrawThreshold: Number(autoWithdrawThreshold), + autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent), + priority, + enabled: enabled && (isReadyToSend || isReadyToReceive) + }, + canSend: isReadyToSend, + canReceive: isReadyToReceive, + priorityOnly + } + const { data: mutationResult, errors: mutationErrors } = await client.mutate({ + mutation, + variables }) + + if (mutationErrors) { + throw new Error(mutationErrors[0].message) + } + + // grab and update wallet ref + const newWallet = mutationResult[generateResolverName(walletDef.walletField)] + innerSetCurrentWallet(newWallet) + + // set client config + const writeVault = openVault(client, me, newWallet, {}) + try { + await writeVault.set(walletDef.name, newClientConfig) + } finally { + await writeVault.close() + } } finally { client.refetchQueries({ include: ['WalletLogs'] }) - refetchConfig() + await refreshConfig() } - }, [client, walletId]) + }, [config, currentWallet, canSend, canReceive]) - const clearConfig = useCallback(async () => { + const clearConfig = useCallback(async ({ logger, clientOnly, ...options }) => { // only remove wallet if there is a wallet to remove - if (!walletId) return - + if (!currentWallet?.id) return try { - await client.mutate({ - mutation: REMOVE_WALLET, - variables: { id: walletId } - }) + const clearVault = openVault(client, me, currentWallet, {}) + try { + await clearVault.clear(walletDef?.name, { onlyFromLocalStorage: clientOnly }) + } catch (e) { + toaster.danger(`failed to clear client config for ${walletDef.name}: ${e.message}`) + } finally { + await clearVault.close() + } + + if (!clientOnly) { + try { + await client.mutate({ + mutation: REMOVE_WALLET, + variables: { id: currentWallet.id } + }) + } catch (e) { + toaster.danger(`failed to remove wallet ${currentWallet.id}: ${e.message}`) + } + } } finally { client.refetchQueries({ include: ['WalletLogs'] }) - refetchConfig() + await refreshConfig() } - }, [client, walletId]) + }, [config, currentWallet]) return [config, saveConfig, clearConfig] } @@ -350,22 +386,30 @@ function generateMutation (wallet) { headerArgs += wallet.fields .filter(isServerField) .map(f => { - let arg = `$${f.name}: String` - if (!f.optional) { - arg += '!' - } + const arg = `$${f.name}: String` + // required fields are checked server-side + // if (!f.optional) { + // arg += '!' + // } return arg }).join(', ') - headerArgs += ', $settings: AutowithdrawSettings!, $priorityOnly: Boolean' + headerArgs += ', $settings: AutowithdrawSettings!, $priorityOnly: Boolean, $canSend: Boolean!, $canReceive: Boolean!' let inputArgs = 'id: $id, ' inputArgs += wallet.fields .filter(isServerField) .map(f => `${f.name}: $${f.name}`).join(', ') - inputArgs += ', settings: $settings, priorityOnly: $priorityOnly' + inputArgs += ', settings: $settings, priorityOnly: $priorityOnly, canSend: $canSend, canReceive: $canReceive,' return gql`mutation ${resolverName}(${headerArgs}) { - ${resolverName}(${inputArgs}) + ${resolverName}(${inputArgs}) { + id, + type, + enabled, + priority, + canReceive, + canSend + } }` } @@ -377,20 +421,6 @@ export function getWalletByType (type) { return walletDefs.find(def => def.walletType === type) } -export function getEnabledWallet (me) { - return walletDefs - .filter(def => !!def.sendPayment) - .map(def => { - // populate definition with properties from useWallet that are required for sorting - const key = getStorageKey(def.name, me) - const config = SSR ? null : JSON.parse(window?.localStorage.getItem(key)) - const priority = config?.priority - return { ...def, config, priority } - }) - .filter(({ config }) => config?.enabled) - .sort(walletPrioritySort)[0] -} - export function walletPrioritySort (w1, w2) { const delta = w1.priority - w2.priority // delta is NaN if either priority is undefined @@ -416,37 +446,16 @@ export function useWallets () { const resetClient = useCallback(async (wallet) => { for (const w of wallets) { if (w.canSend) { - await w.delete({ clientOnly: true }) + await w.delete({ clientOnly: true, onlyFromLocalStorage: true }) } await w.deleteLogs({ clientOnly: true }) } - }, [wallets]) + }, wallets) - return { wallets, resetClient } -} - -function getStorageKey (name, me) { - let storageKey = `wallet:${name}` - - // WebLN has no credentials we need to scope to users - // so we can use the same storage key for all users - if (me && name !== 'webln') { - storageKey = `${storageKey}:${me.id}` - } - - return storageKey -} - -function enableWallet (name, me) { - const key = getStorageKey(name, me) - const config = JSON.parse(window.localStorage.getItem(key)) || {} - config.enabled = true - window.localStorage.setItem(key, JSON.stringify(config)) -} - -function disableWallet (name, me) { - const key = getStorageKey(name, me) - const config = JSON.parse(window.localStorage.getItem(key)) || {} - config.enabled = false - window.localStorage.setItem(key, JSON.stringify(config)) + const [walletsReady, setWalletsReady] = useState([]) + useEffect(() => { + setWalletsReady(wallets.filter(w => w)) + }, wallets) + + return { wallets: walletsReady, resetClient } } diff --git a/wallets/lightning-address/index.js b/wallets/lightning-address/index.js index ff502a3a..cf8d5055 100644 --- a/wallets/lightning-address/index.js +++ b/wallets/lightning-address/index.js @@ -2,6 +2,9 @@ import { lnAddrAutowithdrawSchema } from '@/lib/validate' export const name = 'lightning-address' export const shortName = 'lnAddr' +export const walletType = 'LIGHTNING_ADDRESS' +export const walletField = 'walletLightningAddress' +export const fieldValidation = lnAddrAutowithdrawSchema export const fields = [ { @@ -17,9 +20,3 @@ export const card = { subtitle: 'autowithdraw to a lightning address', badges: ['receive only'] } - -export const fieldValidation = lnAddrAutowithdrawSchema - -export const walletType = 'LIGHTNING_ADDRESS' - -export const walletField = 'walletLightningAddress' diff --git a/wallets/lnbits/index.js b/wallets/lnbits/index.js index 3473f47e..fd772efd 100644 --- a/wallets/lnbits/index.js +++ b/wallets/lnbits/index.js @@ -1,6 +1,9 @@ import { lnbitsSchema } from '@/lib/validate' export const name = 'lnbits' +export const walletType = 'LNBITS' +export const walletField = 'walletLNbits' +export const fieldValidation = lnbitsSchema export const fields = [ { @@ -31,9 +34,3 @@ export const card = { subtitle: 'use [LNbits](https://lnbits.com/) for payments', badges: ['send & receive'] } - -export const fieldValidation = lnbitsSchema - -export const walletType = 'LNBITS' - -export const walletField = 'walletLNbits' diff --git a/wallets/lnc/index.js b/wallets/lnc/index.js index e349b6dd..a834857c 100644 --- a/wallets/lnc/index.js +++ b/wallets/lnc/index.js @@ -1,6 +1,10 @@ import { lncSchema } from '@/lib/validate' export const name = 'lnc' +export const walletType = 'LNC' +export const walletField = 'walletLNC' +export const clientOnly = true +export const fieldValidation = lncSchema export const fields = [ { @@ -35,5 +39,3 @@ export const card = { subtitle: 'use Lightning Node Connect for LND payments', badges: ['send only', 'budgetable'] } - -export const fieldValidation = lncSchema diff --git a/wallets/lnd/index.js b/wallets/lnd/index.js index c884b909..a23fec86 100644 --- a/wallets/lnd/index.js +++ b/wallets/lnd/index.js @@ -1,6 +1,9 @@ import { LNDAutowithdrawSchema } from '@/lib/validate' export const name = 'lnd' +export const walletType = 'LND' +export const walletField = 'walletLND' +export const fieldValidation = LNDAutowithdrawSchema export const fields = [ { @@ -39,9 +42,3 @@ export const card = { subtitle: 'autowithdraw to your Lightning Labs node', badges: ['receive only'] } - -export const fieldValidation = LNDAutowithdrawSchema - -export const walletType = 'LND' - -export const walletField = 'walletLND' diff --git a/wallets/nwc/index.js b/wallets/nwc/index.js index fe443968..2df5e9ca 100644 --- a/wallets/nwc/index.js +++ b/wallets/nwc/index.js @@ -4,6 +4,9 @@ import { nwcSchema } from '@/lib/validate' import { finalizeEvent, nip04, verifyEvent } from 'nostr-tools' export const name = 'nwc' +export const walletType = 'NWC' +export const walletField = 'walletNWC' +export const fieldValidation = nwcSchema export const fields = [ { @@ -30,12 +33,6 @@ export const card = { badges: ['send & receive', 'budgetable'] } -export const fieldValidation = nwcSchema - -export const walletType = 'NWC' - -export const walletField = 'walletNWC' - export async function nwcCall ({ nwcUrl, method, params }, { logger, timeout } = {}) { const { relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl) diff --git a/wallets/phoenixd/index.js b/wallets/phoenixd/index.js index 51625937..ac5b6959 100644 --- a/wallets/phoenixd/index.js +++ b/wallets/phoenixd/index.js @@ -1,6 +1,9 @@ import { phoenixdSchema } from '@/lib/validate' export const name = 'phoenixd' +export const walletType = 'PHOENIXD' +export const walletField = 'walletPhoenixd' +export const fieldValidation = phoenixdSchema // configure wallet fields export const fields = [ @@ -38,8 +41,3 @@ export const card = { // phoenixd::TODO // set validation schema -export const fieldValidation = phoenixdSchema - -export const walletType = 'PHOENIXD' - -export const walletField = 'walletPhoenixd' diff --git a/wallets/server.js b/wallets/server.js index 8f8e8f35..6e2d9cf7 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -1,23 +1,31 @@ +// import server side wallets import * as lnd from 'wallets/lnd/server' import * as cln from 'wallets/cln/server' import * as lnAddr from 'wallets/lightning-address/server' import * as lnbits from 'wallets/lnbits/server' import * as nwc from 'wallets/nwc/server' import * as phoenixd from 'wallets/phoenixd/server' + +// we import only the metadata of client side wallets +import * as blink from 'wallets/blink' +import * as lnc from 'wallets/lnc' +import * as webln from 'wallets/webln' + import { addWalletLog } from '@/api/resolvers/wallet' import walletDefs from 'wallets/server' import { parsePaymentRequest } from 'ln-service' import { toPositiveNumber } from '@/lib/validate' import { PAID_ACTION_TERMINAL_STATES } from '@/lib/constants' import { withTimeout } from '@/lib/time' -export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd] + +export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln] 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 }, + where: { userId, enabled: true, canReceive: true }, include: { user: true }, diff --git a/wallets/webln/client.js b/wallets/webln/client.js index da99bacd..c855cccc 100644 --- a/wallets/webln/client.js +++ b/wallets/webln/client.js @@ -1,3 +1,4 @@ +import { useEffect } from 'react' export * from 'wallets/webln' export const sendPayment = async (bolt11) => { @@ -19,3 +20,32 @@ export const sendPayment = async (bolt11) => { return response.preimage } + +export function isAvailable () { + return typeof window !== 'undefined' && window?.weblnEnabled +} + +export function WebLnProvider ({ children }) { + useEffect(() => { + const onEnable = () => { + window.weblnEnabled = true + } + + const onDisable = () => { + window.weblnEnabled = false + } + + if (!window.webln) onDisable() + else onEnable() + + window.addEventListener('webln:enabled', onEnable) + // event is not fired by Alby browser extension but added here for sake of completeness + window.addEventListener('webln:disabled', onDisable) + return () => { + window.removeEventListener('webln:enabled', onEnable) + window.removeEventListener('webln:disabled', onDisable) + } + }, []) + + return children +} diff --git a/wallets/webln/index.js b/wallets/webln/index.js index 6bfb26d5..04a01075 100644 --- a/wallets/webln/index.js +++ b/wallets/webln/index.js @@ -1,12 +1,12 @@ -import { useEffect } from 'react' -import { useWallet } from 'wallets' - export const name = 'webln' +export const walletType = 'WEBLN' +export const walletField = 'walletWebLN' +export const clientOnly = true export const fields = [] export const fieldValidation = ({ enabled }) => { - if (typeof window.webln === 'undefined') { + if (typeof window?.webln === 'undefined') { // don't prevent disabling WebLN if no WebLN provider found if (enabled) { return { @@ -22,27 +22,3 @@ export const card = { subtitle: 'use a [WebLN provider](https://www.webln.guide/ressources/webln-providers) for payments', badges: ['send only'] } - -export default function WebLnProvider ({ children }) { - const wallet = useWallet(name) - - useEffect(() => { - const onEnable = () => { - wallet.enablePayments() - } - - const onDisable = () => { - wallet.disablePayments() - } - - window.addEventListener('webln:enabled', onEnable) - // event is not fired by Alby browser extension but added here for sake of completeness - window.addEventListener('webln:disabled', onDisable) - return () => { - window.removeEventListener('webln:enabled', onEnable) - window.removeEventListener('webln:disabled', onDisable) - } - }, []) - - return children -} From 49cf1f2e23eac61c782bea0dec5777f6c220cfa3 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Mon, 14 Oct 2024 17:51:46 +0200 Subject: [PATCH 02/58] fix window checks for SSR --- components/use-local-storage.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/use-local-storage.js b/components/use-local-storage.js index fcf09aa2..732d9ded 100644 --- a/components/use-local-storage.js +++ b/components/use-local-storage.js @@ -70,10 +70,10 @@ export async function listLocalStorages ({ userId, database }) { */ function createMemBackend (userId, namespace) { const joinedNamespace = userId + ':' + namespace.join(':') - let memory = window?.snMemStorage?.[joinedNamespace] + let memory = typeof window !== 'undefined' ? window?.snMemStorage?.[joinedNamespace] : null if (!memory) { memory = {} - if (window) { + if (typeof window !== 'undefined') { if (!window.snMemStorage) window.snMemStorage = {} window.snMemStorage[joinedNamespace] = memory } From 4604a7bac9d4c5f5a951c25b329962c3af0fa299 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Mon, 14 Oct 2024 18:01:54 +0200 Subject: [PATCH 03/58] use SSR constant --- components/use-local-storage.js | 12 ++++++------ wallets/webln/client.js | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/components/use-local-storage.js b/components/use-local-storage.js index 732d9ded..c0683bda 100644 --- a/components/use-local-storage.js +++ b/components/use-local-storage.js @@ -70,13 +70,13 @@ export async function listLocalStorages ({ userId, database }) { */ function createMemBackend (userId, namespace) { const joinedNamespace = userId + ':' + namespace.join(':') - let memory = typeof window !== 'undefined' ? window?.snMemStorage?.[joinedNamespace] : null - if (!memory) { + let memory + if (SSR) { memory = {} - if (typeof window !== 'undefined') { - if (!window.snMemStorage) window.snMemStorage = {} - window.snMemStorage[joinedNamespace] = memory - } + } else { + if (!window.snMemStorage) window.snMemStorage = {} + memory = window.snMemStorage[joinedNamespace] + if (!memory) window.snMemStorage[joinedNamespace] = memory = {} } return { set: (key, value) => { memory[key] = value }, diff --git a/wallets/webln/client.js b/wallets/webln/client.js index c855cccc..f47e494c 100644 --- a/wallets/webln/client.js +++ b/wallets/webln/client.js @@ -1,6 +1,6 @@ import { useEffect } from 'react' +import { SSR } from '@/lib/constants' export * from 'wallets/webln' - export const sendPayment = async (bolt11) => { if (typeof window.webln === 'undefined') { throw new Error('WebLN provider not found') @@ -22,7 +22,7 @@ export const sendPayment = async (bolt11) => { } export function isAvailable () { - return typeof window !== 'undefined' && window?.weblnEnabled + return !SSR && window?.weblnEnabled } export function WebLnProvider ({ children }) { From a95e4cd6e957538829823b018dff54fbfc812aea Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Mon, 14 Oct 2024 19:45:45 +0200 Subject: [PATCH 04/58] collect meta from server config --- wallets/index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wallets/index.js b/wallets/index.js index 4ced5302..72515d39 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -260,7 +260,6 @@ function useConfig (walletDef) { const saveConfig = useCallback(async (newConfig, { logger, skipTests }) => { const priorityOnly = skipTests - const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, priority, enabled } = newConfig try { // gather configs @@ -287,10 +286,11 @@ function useConfig (walletDef) { // check if it misses send or receive configs const isReadyToSend = canSend && isConfigured({ fields: walletDef.fields, config: newConfig, clientOnly: true }) const isReadyToReceive = canReceive && isConfigured({ fields: walletDef.fields, config: newConfig, serverOnly: true }) + const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, priority, enabled } = newServerConfig // console.log('New client config', newClientConfig) // console.log('New server config', newServerConfig) - // console.log('Sender', isReadyToSend, 'Receiver', isReadyToReceive, 'enabled', enabled) + // console.log('Sender', isReadyToSend, 'Receiver', isReadyToReceive, 'enabled', enabled, autoWithdrawThreshold, autoWithdrawMaxFeePercent, priority) // client test if (!skipTests && isReadyToSend) { From 6f1113636fa7c0e2260d6636803883e22300efd8 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Mon, 14 Oct 2024 19:46:12 +0200 Subject: [PATCH 05/58] fix: await in transaction --- api/resolvers/wallet.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index a98bec6d..c2fe95fe 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -682,7 +682,7 @@ async function upsertWallet ( return await models.$transaction(async (tx) => { if (canReceive) { - tx.user.update({ + await tx.user.update({ where: { id: me.id }, data: { autoWithdrawMaxFeePercent, @@ -699,7 +699,7 @@ async function upsertWallet ( }) : undefined - updatedWallet = tx.wallet.update({ + updatedWallet = await tx.wallet.update({ where: { id: Number(id), userId: me.id }, data: { enabled, @@ -720,7 +720,7 @@ async function upsertWallet ( } }) } else { - updatedWallet = tx.wallet.create({ + updatedWallet = await tx.wallet.create({ data: { enabled, priority, @@ -771,7 +771,7 @@ async function upsertWallet ( }) } - tx.walletLog.createMany({ + await tx.walletLog.createMany({ data: logs }) From 4aa96082126e2964c9f041e7d3342e1a25bf817f Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Tue, 15 Oct 2024 00:17:32 +0200 Subject: [PATCH 06/58] fixed and add wallet migration --- pages/_app.js | 31 +++++++++++++++++-------------- wallets/index.js | 46 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 58 insertions(+), 19 deletions(-) diff --git a/pages/_app.js b/pages/_app.js index 6bb9d10d..7980945a 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -22,6 +22,7 @@ import dynamic from 'next/dynamic' import { HasNewNotesProvider } from '@/components/use-has-new-notes' import { WebLnProvider } from '@/wallets/webln/client' import { AccountProvider } from '@/components/account' +import { WalletsMigrator } from '@/wallets/index' const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false }) @@ -110,20 +111,22 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - - - - - - - - {!router?.query?.disablePrompt && } - - - - - - + + + + + + + + + {!router?.query?.disablePrompt && } + + + + + + + diff --git a/wallets/index.js b/wallets/index.js index 72515d39..5406bd4e 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -12,7 +12,7 @@ import { useShowModal } from '@/components/modal' import { useToast } from '../components/toast' import { generateResolverName, isConfigured, isClientField, isServerField } from '@/lib/wallet' import { walletValidate } from '@/lib/validate' - +import { SSR } from '@/lib/constants' export const Status = { Initialized: 'Initialized', Enabled: 'Enabled', @@ -88,6 +88,10 @@ export function useWallet (name) { const save = useCallback(async (newConfig) => { await saveConfig(newConfig, { logger }) + const available = (!walletDef.isAvailable || walletDef.isAvailable()) + logger.ok(_isConfigured() ? 'payment details updated' : 'wallet attached for payments') + if (newConfig.enabled && available) logger.ok('payments enabled') + else logger.ok('payments disabled') }, [saveConfig, me]) // delete is a reserved keyword @@ -130,14 +134,12 @@ export function useWallet (name) { wallet.logger = logger wallet.sendPayment = sendPayment wallet.def = walletDef - logger.ok(walletDef.isConfigured ? 'payment details updated' : 'wallet attached for payments') return wallet }, [walletDef, config, status, enabled, priority, logger, enablePayments, disablePayments, save, delete_, deleteLogs_, setPriority, hasConfig]) useEffect(() => { if (wallet.enabled && wallet.canSend) { disableFreebies().catch(console.error) - logger.ok('payments enabled') } }, [wallet]) @@ -312,8 +314,8 @@ function useConfig (walletDef) { ...newServerConfig, id: currentWallet?.id, settings: { - autoWithdrawThreshold: Number(autoWithdrawThreshold), - autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent), + autoWithdrawThreshold: Number(autoWithdrawThreshold == null ? autowithdrawSettings.autoWithdrawThreshold : autoWithdrawThreshold), + autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent == null ? autowithdrawSettings.autoWithdrawMaxFeePercent : autoWithdrawMaxFeePercent), priority, enabled: enabled && (isReadyToSend || isReadyToReceive) }, @@ -459,3 +461,37 @@ export function useWallets () { return { wallets: walletsReady, resetClient } } + +export function WalletsMigrator ({ children }) { + const { me } = useMe() + const { wallets } = useWallets() + const keys = !SSR ? Object.keys(window.localStorage).filter(k => k.startsWith('wallet:')) : [] + const ran = useRef(false) + useEffect(() => { + if (SSR || !me?.id || !wallets.length) return + if (ran.current) return + ran.current = true + if (!keys?.length) { + console.log('wallet migrator: nothing to migrate', keys) + return + } + const userId = me.id + // List all local storage keys related to wallet settings + const userKeys = keys.filter(k => k.endsWith(`:${userId}`)) + ;(async () => { + for (const key of userKeys) { + const walletType = key.substring('wallet:'.length, key.length - userId.length - 1) + const walletConfig = JSON.parse(window.localStorage.getItem(key)) + const wallet = wallets.find(w => w.def.name === walletType) + if (wallet) { + console.log('Migrating', walletType, walletConfig) + await wallet.save(walletConfig) + window.localStorage.removeItem(key) + } else { + console.warn('No wallet found for', walletType, wallets) + } + } + })() + }, [me, wallets]) + return children +} From 6bd07284a5cd66b3a42b86fd5a22179ac960e0cd Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Tue, 15 Oct 2024 00:34:20 +0200 Subject: [PATCH 07/58] optimize api calls --- pages/_app.js | 6 +++--- wallets/index.js | 51 ++++++++++++++++++++++++++---------------------- 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/pages/_app.js b/pages/_app.js index 7980945a..6c27b837 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -22,7 +22,7 @@ import dynamic from 'next/dynamic' import { HasNewNotesProvider } from '@/components/use-has-new-notes' import { WebLnProvider } from '@/wallets/webln/client' import { AccountProvider } from '@/components/account' -import { WalletsMigrator } from '@/wallets/index' +import { WalletProvider } from '@/wallets/index' const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false }) @@ -111,7 +111,7 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - + @@ -126,7 +126,7 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - + diff --git a/wallets/index.js b/wallets/index.js index 5406bd4e..f81ffe9f 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -1,4 +1,4 @@ -import { useCallback, useState, useEffect, useRef, useMemo } from 'react' +import { useContext, createContext, useCallback, useState, useEffect, useRef, useMemo } from 'react' import { useMe } from '@/components/me' import { openVault } from '@/components/use-vault' import { useWalletLogger } from '@/components/wallet-logger' @@ -137,12 +137,6 @@ export function useWallet (name) { return wallet }, [walletDef, config, status, enabled, priority, logger, enablePayments, disablePayments, save, delete_, deleteLogs_, setPriority, hasConfig]) - useEffect(() => { - if (wallet.enabled && wallet.canSend) { - disableFreebies().catch(console.error) - } - }, [wallet]) - return wallet } @@ -442,9 +436,12 @@ export function walletPrioritySort (w1, w2) { return w1.card.title < w2.card.title ? -1 : 1 } -export function useWallets () { - const wallets = walletDefs.map(def => useWallet(def.name)) +const WalletContext = createContext({ + wallets: [] +}) +export function useWallets () { + const { wallets } = useContext(WalletContext) const resetClient = useCallback(async (wallet) => { for (const w of wallets) { if (w.canSend) { @@ -453,31 +450,35 @@ export function useWallets () { await w.deleteLogs({ clientOnly: true }) } }, wallets) + return { wallets, resetClient } +} + +export function WalletProvider ({ children }) { + if (SSR) return children + + const { me } = useMe() + const migrationRan = useRef(false) + const migratableKeys = !migrationRan.current && !SSR ? Object.keys(window.localStorage).filter(k => k.startsWith('wallet:')) : undefined + + const wallets = walletDefs.map(def => useWallet(def.name)) const [walletsReady, setWalletsReady] = useState([]) useEffect(() => { setWalletsReady(wallets.filter(w => w)) }, wallets) - return { wallets: walletsReady, resetClient } -} - -export function WalletsMigrator ({ children }) { - const { me } = useMe() - const { wallets } = useWallets() - const keys = !SSR ? Object.keys(window.localStorage).filter(k => k.startsWith('wallet:')) : [] - const ran = useRef(false) + // migration useEffect(() => { if (SSR || !me?.id || !wallets.length) return - if (ran.current) return - ran.current = true - if (!keys?.length) { - console.log('wallet migrator: nothing to migrate', keys) + if (migrationRan.current) return + migrationRan.current = true + if (!migratableKeys?.length) { + console.log('wallet migrator: nothing to migrate', migratableKeys) return } const userId = me.id // List all local storage keys related to wallet settings - const userKeys = keys.filter(k => k.endsWith(`:${userId}`)) + const userKeys = migratableKeys.filter(k => k.endsWith(`:${userId}`)) ;(async () => { for (const key of userKeys) { const walletType = key.substring('wallet:'.length, key.length - userId.length - 1) @@ -493,5 +494,9 @@ export function WalletsMigrator ({ children }) { } })() }, [me, wallets]) - return children + return ( + + {children} + + ) } From 4fce6fa234a32a1c53ae09648628c11c38216834 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Tue, 15 Oct 2024 14:59:45 +0200 Subject: [PATCH 08/58] Fix for enabled but not available wallets --- wallets/index.js | 38 ++++++++++++++++---------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/wallets/index.js b/wallets/index.js index f81ffe9f..fd0eecbd 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -1,4 +1,4 @@ -import { useContext, createContext, useCallback, useState, useEffect, useRef, useMemo } from 'react' +import { useCallback, useState, useEffect, useRef, useMemo } from 'react' import { useMe } from '@/components/me' import { openVault } from '@/components/use-vault' import { useWalletLogger } from '@/components/wallet-logger' @@ -42,8 +42,9 @@ export function useWallet (name) { const { logger, deleteLogs } = useWalletLogger(walletDef) const [config, saveConfig, clearConfig] = useConfig(walletDef) + const available = (!walletDef?.isAvailable || walletDef?.isAvailable()) - const status = config?.enabled ? Status.Enabled : Status.Initialized + const status = config?.enabled && available ? Status.Enabled : Status.Initialized const enabled = status === Status.Enabled const priority = config?.priority const hasConfig = walletDef?.fields?.length > 0 @@ -113,7 +114,6 @@ export function useWallet (name) { const wallet = useMemo(() => { if (!walletDef) return {} - const available = (!walletDef.isAvailable || walletDef.isAvailable()) const wallet = { ...walletDef } @@ -129,7 +129,8 @@ export function useWallet (name) { wallet.setPriority = setPriority wallet.hasConfig = hasConfig wallet.status = status - wallet.enabled = enabled && available + wallet.enabled = enabled + wallet.available = available wallet.priority = priority wallet.logger = logger wallet.sendPayment = sendPayment @@ -436,12 +437,14 @@ export function walletPrioritySort (w1, w2) { return w1.card.title < w2.card.title ? -1 : 1 } -const WalletContext = createContext({ - wallets: [] -}) - export function useWallets () { - const { wallets } = useContext(WalletContext) + const wallets = walletDefs.map(def => useWallet(def.name)) + + const [walletsReady, setWalletsReady] = useState([]) + useEffect(() => { + setWalletsReady(wallets.filter(w => w)) + }, wallets) + const resetClient = useCallback(async (wallet) => { for (const w of wallets) { if (w.canSend) { @@ -450,7 +453,7 @@ export function useWallets () { await w.deleteLogs({ clientOnly: true }) } }, wallets) - return { wallets, resetClient } + return { wallets: walletsReady, resetClient } } export function WalletProvider ({ children }) { @@ -459,13 +462,7 @@ export function WalletProvider ({ children }) { const { me } = useMe() const migrationRan = useRef(false) const migratableKeys = !migrationRan.current && !SSR ? Object.keys(window.localStorage).filter(k => k.startsWith('wallet:')) : undefined - - const wallets = walletDefs.map(def => useWallet(def.name)) - - const [walletsReady, setWalletsReady] = useState([]) - useEffect(() => { - setWalletsReady(wallets.filter(w => w)) - }, wallets) + const { wallets } = useWallets() // migration useEffect(() => { @@ -494,9 +491,6 @@ export function WalletProvider ({ children }) { } })() }, [me, wallets]) - return ( - - {children} - - ) + + return children } From 06afe2cda2cb9e2170ac75ba5a2c0979b581086e Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Tue, 15 Oct 2024 15:12:42 +0200 Subject: [PATCH 09/58] remove debug log --- components/use-vault.js | 1 - 1 file changed, 1 deletion(-) diff --git a/components/use-vault.js b/components/use-vault.js index 3383c786..6eb254b9 100644 --- a/components/use-vault.js +++ b/components/use-vault.js @@ -244,7 +244,6 @@ export function openVault (apollo, user, owner) { nextFetchPolicy: 'no-cache', fetchPolicy: 'no-cache' }) - console.log(queriedData) if (queriedError) throw queriedError const encryptedVaultValue = queriedData?.getVaultEntry?.value if (encryptedVaultValue) { From d30502a011e5a3c962066c5269c37a745548874b Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Tue, 15 Oct 2024 15:13:04 +0200 Subject: [PATCH 10/58] fix wallet filtering --- api/resolvers/wallet.js | 22 ++++++++++++++++------ wallets/index.js | 9 ++++++--- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index c2fe95fe..0da5fbca 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -181,13 +181,23 @@ const resolvers = { throw new GqlAuthenticationError() } + const filter = { + userId: me.id + } + + if (includeReceivers && includeSenders) { + filter.OR = [ + { canReceive: true }, + { canSend: true } + ] + } else if (includeReceivers) { + filter.canReceive = true + } else if (includeSenders) { + filter.canSend = true + } + return await models.wallet.findMany({ - where: { - userId: me.id, - canReceive: includeReceivers, - canSend: includeSenders, - enabled: onlyEnabled !== undefined ? onlyEnabled : undefined - }, + where: filter, orderBy: { priority: 'desc' } diff --git a/wallets/index.js b/wallets/index.js index fd0eecbd..a0987ef7 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -34,8 +34,8 @@ export function useWallet (name) { // .filter(w => w.enabled && w.canSend)// filtered by the server // .sort((a, b) => b.priority - a.priority) // already priority sorted by the server .map(w => getWalletByType(w.type)) - .filter(w => !w.isAvailable || w.isAvailable())[0] - name = bestWalletDef?.name + .filter(w => !w.isAvailable || w.isAvailable()) + name = bestWalletDef?.[0]?.name } const walletDef = getWalletByName(name) @@ -113,7 +113,10 @@ export function useWallet (name) { }, [deleteLogs]) const wallet = useMemo(() => { - if (!walletDef) return {} + if (!walletDef) { + console.log(name) + return {} + } const wallet = { ...walletDef } From f438b278bca1c7f3c1187ddeec48f8b72c619596 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Tue, 15 Oct 2024 17:02:00 +0200 Subject: [PATCH 11/58] fix priority sorting --- api/resolvers/wallet.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 0da5fbca..b66da95d 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -199,7 +199,7 @@ const resolvers = { return await models.wallet.findMany({ where: filter, orderBy: { - priority: 'desc' + priority: 'asc' } }) }, From 87c5634b55be96973fa86358e2f73a71db3473dc Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Tue, 15 Oct 2024 17:10:59 +0200 Subject: [PATCH 12/58] add debug log --- wallets/server.js | 1 + 1 file changed, 1 insertion(+) diff --git a/wallets/server.js b/wallets/server.js index 6e2d9cf7..12dc2ce0 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -81,6 +81,7 @@ export async function createInvoice (userId, { msats, description, descriptionHa if (pendingWithdrawals + pendingForwards >= MAX_PENDING_INVOICES_PER_WALLET) { throw new Error('wallet has too many pending invoices') } + console.log('use wallet', walletType) const invoice = await withTimeout( createInvoice({ From aded5ac422722eed4baa734a194a71b96d1a74ee Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Tue, 15 Oct 2024 17:12:14 +0200 Subject: [PATCH 13/58] fixes --- wallets/index.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/wallets/index.js b/wallets/index.js index a0987ef7..1e0b2860 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -30,12 +30,15 @@ export function useWallet (name) { if (!name) { // find best wallet in list - const bestWalletDef = bestSendWalletList?.wallets + const highestWalletDef = bestSendWalletList?.wallets // .filter(w => w.enabled && w.canSend)// filtered by the server // .sort((a, b) => b.priority - a.priority) // already priority sorted by the server .map(w => getWalletByType(w.type)) - .filter(w => !w.isAvailable || w.isAvailable()) - name = bestWalletDef?.[0]?.name + + const highestAvailableWalletDef = highestWalletDef?.filter(w => !w.isAvailable || w.isAvailable()) + // console.log('Wallets priority', bestAvailableWallet.map(w => w.name)) + // console.log('Available wallets priority', bestAvailableWallet.map(w => w.name)) + name = highestAvailableWalletDef?.[0]?.name } const walletDef = getWalletByName(name) @@ -114,8 +117,7 @@ export function useWallet (name) { const wallet = useMemo(() => { if (!walletDef) { - console.log(name) - return {} + return undefined } const wallet = { ...walletDef From 2ef7651421630d652d4874b10870c0b25ba00eea Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Tue, 15 Oct 2024 23:00:01 +0200 Subject: [PATCH 14/58] optimize api calls, remove useless effects --- api/resolvers/wallet.js | 6 +- api/typeDefs/wallet.js | 1 + fragments/wallet.js | 1 + pages/_app.js | 28 ++++----- wallets/index.js | 131 +++++++++++++++++++++------------------- 5 files changed, 89 insertions(+), 78 deletions(-) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index b66da95d..c2c0d8a2 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -195,13 +195,17 @@ const resolvers = { } else if (includeSenders) { filter.canSend = true } + if (onlyEnabled) { + filter.enabled = true + } - return await models.wallet.findMany({ + const out = await models.wallet.findMany({ where: filter, orderBy: { priority: 'asc' } }) + return out }, withdrawl: getWithdrawl, numBolt11s: async (parent, args, { me, models, lnd }) => { diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index a2137e27..c17e38c3 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -86,6 +86,7 @@ const typeDefs = ` type Wallet implements VaultOwner { id: ID! createdAt: Date! + updatedAt: Date! type: String! enabled: Boolean! priority: Int! diff --git a/fragments/wallet.js b/fragments/wallet.js index 9cead739..ea007f73 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -204,6 +204,7 @@ export const BEST_SEND_WALLETS = gql` id priority type + updatedAt } } ` diff --git a/pages/_app.js b/pages/_app.js index 6c27b837..3320fcb2 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -105,13 +105,13 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - - - - - - - + + + + + + + @@ -126,13 +126,13 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - - - - - - - + + + + + + + diff --git a/wallets/index.js b/wallets/index.js index 1e0b2860..6ef6efbc 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -1,4 +1,4 @@ -import { useCallback, useState, useEffect, useRef, useMemo } from 'react' +import { createContext, useContext, useCallback, useState, useEffect, useRef } from 'react' import { useMe } from '@/components/me' import { openVault } from '@/components/use-vault' import { useWalletLogger } from '@/components/wallet-logger' @@ -12,7 +12,8 @@ import { useShowModal } from '@/components/modal' import { useToast } from '../components/toast' import { generateResolverName, isConfigured, isClientField, isServerField } from '@/lib/wallet' import { walletValidate } from '@/lib/validate' -import { SSR } from '@/lib/constants' +import { SSR, FAST_POLL_INTERVAL as POLL_INTERVAL } from '@/lib/constants' + export const Status = { Initialized: 'Initialized', Enabled: 'Enabled', @@ -20,27 +21,30 @@ export const Status = { Error: 'Error' } +const WalletContext = createContext({ + wallets: [], + sendWallets: [] +}) + export function useWallet (name) { + const context = useContext(WalletContext) + const bestSendWalletList = context.sendWallets + if (!name) { + // find best wallet in list + const highestWalletDef = bestSendWalletList?.map(w => getWalletByType(w.type)) + .filter(w => !w.isAvailable || w.isAvailable()) + name = highestWalletDef?.[0]?.name + } + const wallet = context.wallets.find(w => w.def.name === name) + return wallet +} + +function useWalletInner (name) { const { me } = useMe() const showModal = useShowModal() const toaster = useToast() const [disableFreebies] = useMutation(gql`mutation { disableFreebies }`) - const { data: bestSendWalletList } = useQuery(BEST_SEND_WALLETS) - - if (!name) { - // find best wallet in list - const highestWalletDef = bestSendWalletList?.wallets - // .filter(w => w.enabled && w.canSend)// filtered by the server - // .sort((a, b) => b.priority - a.priority) // already priority sorted by the server - .map(w => getWalletByType(w.type)) - - const highestAvailableWalletDef = highestWalletDef?.filter(w => !w.isAvailable || w.isAvailable()) - // console.log('Wallets priority', bestAvailableWallet.map(w => w.name)) - // console.log('Available wallets priority', bestAvailableWallet.map(w => w.name)) - name = highestAvailableWalletDef?.[0]?.name - } - const walletDef = getWalletByName(name) const { logger, deleteLogs } = useWalletLogger(walletDef) @@ -51,7 +55,6 @@ export function useWallet (name) { const enabled = status === Status.Enabled const priority = config?.priority const hasConfig = walletDef?.fields?.length > 0 - const _isConfigured = useCallback(() => { return isConfigured({ ...walletDef, config }) }, [walletDef, config]) @@ -78,7 +81,7 @@ export function useWallet (name) { logger.error('payment failed:', `payment_hash=${hash}`, message) throw err } - }, [me, walletDef, config, status]) + }, [me, walletDef, config]) const setPriority = useCallback(async (priority) => { if (_isConfigured() && priority !== config.priority) { @@ -115,33 +118,28 @@ export function useWallet (name) { return await deleteLogs(options) }, [deleteLogs]) - const wallet = useMemo(() => { - if (!walletDef) { - return undefined - } - const wallet = { - ...walletDef - } - wallet.isConfigured = _isConfigured() - wallet.enablePayments = enablePayments - wallet.disablePayments = disablePayments - wallet.canSend = config.canSend && available - wallet.canReceive = config.canReceive - wallet.config = config - wallet.save = save - wallet.delete = delete_ - wallet.deleteLogs = deleteLogs_ - wallet.setPriority = setPriority - wallet.hasConfig = hasConfig - wallet.status = status - wallet.enabled = enabled - wallet.available = available - wallet.priority = priority - wallet.logger = logger - wallet.sendPayment = sendPayment - wallet.def = walletDef - return wallet - }, [walletDef, config, status, enabled, priority, logger, enablePayments, disablePayments, save, delete_, deleteLogs_, setPriority, hasConfig]) + if (!walletDef) return null + + const wallet = { ...walletDef } + + wallet.isConfigured = _isConfigured() + wallet.enablePayments = enablePayments + wallet.disablePayments = disablePayments + wallet.canSend = config.canSend && available + wallet.canReceive = config.canReceive + wallet.config = config + wallet.save = save + wallet.delete = delete_ + wallet.deleteLogs = deleteLogs_ + wallet.setPriority = setPriority + wallet.hasConfig = hasConfig + wallet.status = status + wallet.enabled = enabled + wallet.available = available + wallet.priority = priority + wallet.logger = logger + wallet.sendPayment = sendPayment + wallet.def = walletDef return wallet } @@ -219,9 +217,7 @@ function useConfig (walletDef) { // fetch client config let clientConfig = {} if (serverConfig?.data?.walletByType) { - if (clientVault.current) { - clientVault.current.close() - } + if (clientVault.current) clientVault.current.close() const newClientVault = openVault(client, me, serverConfig.data.walletByType) clientVault.current = newClientVault clientConfig = await newClientVault.get(walletDef.name, {}) @@ -443,13 +439,7 @@ export function walletPrioritySort (w1, w2) { } export function useWallets () { - const wallets = walletDefs.map(def => useWallet(def.name)) - - const [walletsReady, setWalletsReady] = useState([]) - useEffect(() => { - setWalletsReady(wallets.filter(w => w)) - }, wallets) - + const { wallets } = useContext(WalletContext) const resetClient = useCallback(async (wallet) => { for (const w of wallets) { if (w.canSend) { @@ -457,17 +447,28 @@ export function useWallets () { } await w.deleteLogs({ clientOnly: true }) } - }, wallets) - return { wallets: walletsReady, resetClient } + }, [wallets]) + return { wallets, resetClient } } export function WalletProvider ({ children }) { - if (SSR) return children - const { me } = useMe() + const wallets = walletDefs.map(def => useWalletInner(def.name)).filter(w => w) + const migrationRan = useRef(false) const migratableKeys = !migrationRan.current && !SSR ? Object.keys(window.localStorage).filter(k => k.startsWith('wallet:')) : undefined - const { wallets } = useWallets() + + const { data: bestSendWalletListData } = useQuery(BEST_SEND_WALLETS, { + pollInterval: POLL_INTERVAL, + nextFetchPolicy: 'network-only', + fetchPolicy: 'network-only' + }) + + const [bestSendWalletList, setBestSendWalletList] = useState(bestSendWalletListData?.wallets ?? []) + + useEffect(() => { + setBestSendWalletList(bestSendWalletListData?.wallets) + }, [bestSendWalletListData]) // migration useEffect(() => { @@ -495,7 +496,11 @@ export function WalletProvider ({ children }) { } } })() - }, [me, wallets]) + }, []) - return children + return ( + + {children} + + ) } From 40f24236fd45bda35ed6672d9ca22e46c096aa24 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Tue, 15 Oct 2024 23:07:25 +0200 Subject: [PATCH 15/58] show enabled only if configured to receive or send (handle client settings wipe) --- wallets/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wallets/index.js b/wallets/index.js index 6ef6efbc..9c084e45 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -51,7 +51,7 @@ function useWalletInner (name) { const [config, saveConfig, clearConfig] = useConfig(walletDef) const available = (!walletDef?.isAvailable || walletDef?.isAvailable()) - const status = config?.enabled && available ? Status.Enabled : Status.Initialized + const status = config?.enabled && available && (config.canSend || config.canReceive) ? Status.Enabled : Status.Initialized const enabled = status === Status.Enabled const priority = config?.priority const hasConfig = walletDef?.fields?.length > 0 From bb91b629f7714001bb2b6a079336216aad5b44c7 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Wed, 16 Oct 2024 09:21:05 +0200 Subject: [PATCH 16/58] make sorting optional --- api/resolvers/wallet.js | 4 ++-- api/typeDefs/wallet.js | 2 +- fragments/wallet.js | 14 ++++++++++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index c2c0d8a2..4ec27a8c 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -176,7 +176,7 @@ const resolvers = { }) return wallet }, - wallets: async (parent, { includeReceivers = true, includeSenders = true, onlyEnabled = false }, { me, models }) => { + wallets: async (parent, { includeReceivers = true, includeSenders = true, onlyEnabled = false, prioritySort = undefined }, { me, models }) => { if (!me) { throw new GqlAuthenticationError() } @@ -202,7 +202,7 @@ const resolvers = { const out = await models.wallet.findMany({ where: filter, orderBy: { - priority: 'asc' + priority: prioritySort } }) return out diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index c17e38c3..86b1d559 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -67,7 +67,7 @@ const typeDefs = ` numBolt11s: Int! connectAddress: String! walletHistory(cursor: String, inc: String): History - wallets(includeReceivers: Boolean, includeSenders: Boolean, onlyEnabled: Boolean): [Wallet!]! + wallets(includeReceivers: Boolean, includeSenders: Boolean, onlyEnabled: Boolean, prioritySort: String): [Wallet!]! wallet(id: ID!): Wallet walletByType(type: String!): Wallet walletLogs(type: String, from: String, to: String, cursor: String): WalletLog! diff --git a/fragments/wallet.js b/fragments/wallet.js index ea007f73..af8ba5a1 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -198,9 +198,19 @@ export const WALLETS = gql` } ` +export const BEST_WALLETS = gql` + query BestWallets { + wallets (includeSenders: true, includeReceivers: true, onlyEnabled: true, prioritySort: "asc") { + id + priority + type + updatedAt + } + } +` export const BEST_SEND_WALLETS = gql` - query SendWallets { - wallets (includeSenders: true, includeReceivers: false, onlyEnabled: true) { + query BestWallets { + wallets (includeSenders: true, includeReceivers: false, onlyEnabled: true, prioritySort: "asc") { id priority type From a6665bca6a4aa3ef4c4770f517882a8e02f2dd1f Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Wed, 16 Oct 2024 09:22:43 +0200 Subject: [PATCH 17/58] fix priority sorting for send wallets,caching and sorting --- wallets/index.js | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/wallets/index.js b/wallets/index.js index 9c084e45..dfde7a00 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -196,7 +196,7 @@ function useConfig (walletDef) { const serverConfig = await client.query({ query: WALLET_BY_TYPE, variables: { type: walletDef.walletType }, - fetchPolicy: 'no-cache' + fetchPolicy: 'network-only' }) if (serverConfig?.data?.walletByType) { @@ -284,7 +284,7 @@ function useConfig (walletDef) { // check if it misses send or receive configs const isReadyToSend = canSend && isConfigured({ fields: walletDef.fields, config: newConfig, clientOnly: true }) const isReadyToReceive = canReceive && isConfigured({ fields: walletDef.fields, config: newConfig, serverOnly: true }) - const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, priority, enabled } = newServerConfig + const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, priority, enabled } = newConfig // console.log('New client config', newClientConfig) // console.log('New server config', newServerConfig) @@ -453,22 +453,30 @@ export function useWallets () { export function WalletProvider ({ children }) { const { me } = useMe() - const wallets = walletDefs.map(def => useWalletInner(def.name)).filter(w => w) - const migrationRan = useRef(false) const migratableKeys = !migrationRan.current && !SSR ? Object.keys(window.localStorage).filter(k => k.startsWith('wallet:')) : undefined - const { data: bestSendWalletListData } = useQuery(BEST_SEND_WALLETS, { - pollInterval: POLL_INTERVAL, - nextFetchPolicy: 'network-only', - fetchPolicy: 'network-only' - }) + const walletList = walletDefs.map(def => useWalletInner(def.name)).filter(w => w) + const { data: bestSendWalletList } = useQuery(BEST_SEND_WALLETS, SSR + ? {} + : { + pollInterval: POLL_INTERVAL, + nextFetchPolicy: 'cache-and-network' + }) - const [bestSendWalletList, setBestSendWalletList] = useState(bestSendWalletListData?.wallets ?? []) + const processSendWallets = (bestWalletData) => { + const clientSideSorting = false // sorting is now done on the server + let wallets = (bestWalletData?.wallets ?? []).filter(w => w.canSend) + if (clientSideSorting) wallets = wallets.sort(walletPrioritySort) + return wallets + } + + const wallets = walletList.sort(walletPrioritySort) + const [bestSendWallets, innerSetBestSendWallets] = useState(() => processSendWallets(bestSendWalletList)) useEffect(() => { - setBestSendWalletList(bestSendWalletListData?.wallets) - }, [bestSendWalletListData]) + innerSetBestSendWallets(processSendWallets(bestSendWalletList)) + }, [bestSendWalletList]) // migration useEffect(() => { @@ -499,7 +507,7 @@ export function WalletProvider ({ children }) { }, []) return ( - + {children} ) From 1beac3a405831a61763b50a1a448457a6af42681 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Wed, 16 Oct 2024 09:47:12 +0200 Subject: [PATCH 18/58] ensure wallet id is in sync before saving the config --- components/use-vault.js | 2 +- wallets/index.js | 36 +++++++++++++++++++++--------------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/components/use-vault.js b/components/use-vault.js index 6eb254b9..7efe861b 100644 --- a/components/use-vault.js +++ b/components/use-vault.js @@ -117,7 +117,7 @@ export function useVaultMigration () { migratedCount++ console.log('migrated to vault:', entryName) } else { - throw new Error('could not set vault entry') + console.log('could not set vault entry:', entryName) } } catch (e) { console.error('failed migrate to vault:', entryName, e) diff --git a/wallets/index.js b/wallets/index.js index dfde7a00..9cd45a8c 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -185,6 +185,15 @@ function useConfig (walletDef) { const canSend = !!walletDef?.sendPayment const canReceive = !walletDef?.clientOnly + const queryServerWallet = useCallback(async () => { + const wallet = await client.query({ + query: WALLET_BY_TYPE, + variables: { type: walletDef.walletType }, + fetchPolicy: 'network-only' + }) + return wallet?.data?.walletByType + }, [walletDef, client]) + const refreshConfig = useCallback(async () => { if (walletDef) { let newConfig = {} @@ -193,32 +202,28 @@ function useConfig (walletDef) { } // fetch server config - const serverConfig = await client.query({ - query: WALLET_BY_TYPE, - variables: { type: walletDef.walletType }, - fetchPolicy: 'network-only' - }) + const serverConfig = await queryServerWallet() - if (serverConfig?.data?.walletByType) { + if (serverConfig) { newConfig = { ...newConfig, - id: serverConfig.data.walletByType.id, - priority: serverConfig.data.walletByType.priority, - enabled: serverConfig.data.walletByType.enabled + id: serverConfig.id, + priority: serverConfig.priority, + enabled: serverConfig.enabled } - if (serverConfig.data.walletByType.wallet) { + if (serverConfig.wallet) { newConfig = { ...newConfig, - ...serverConfig.data.walletByType.wallet + ...serverConfig.wallet } } } // fetch client config let clientConfig = {} - if (serverConfig?.data?.walletByType) { + if (serverConfig) { if (clientVault.current) clientVault.current.close() - const newClientVault = openVault(client, me, serverConfig.data.walletByType) + const newClientVault = openVault(client, me, serverConfig) clientVault.current = newClientVault clientConfig = await newClientVault.get(walletDef.name, {}) if (clientConfig) { @@ -248,7 +253,7 @@ function useConfig (walletDef) { innerSetConfig(newConfig) // set wallet ref - innerSetCurrentWallet(serverConfig.data.walletByType) + innerSetCurrentWallet(serverConfig) } }, [walletDef, me]) @@ -257,6 +262,7 @@ function useConfig (walletDef) { }, [walletDef, me]) const saveConfig = useCallback(async (newConfig, { logger, skipTests }) => { + const serverConfig = await queryServerWallet() const priorityOnly = skipTests try { // gather configs @@ -308,7 +314,7 @@ function useConfig (walletDef) { const mutation = generateMutation(walletDef) const variables = { ...newServerConfig, - id: currentWallet?.id, + id: serverConfig?.id, settings: { autoWithdrawThreshold: Number(autoWithdrawThreshold == null ? autowithdrawSettings.autoWithdrawThreshold : autoWithdrawThreshold), autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent == null ? autowithdrawSettings.autoWithdrawMaxFeePercent : autoWithdrawMaxFeePercent), From 240040f2a30295a2f42025db3c7972d412361031 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Wed, 16 Oct 2024 10:00:06 +0200 Subject: [PATCH 19/58] ensure wallets are kept in-sync between clients --- wallets/index.js | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/wallets/index.js b/wallets/index.js index 9cd45a8c..1e1e822f 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -6,7 +6,7 @@ import { bolt11Tags } from '@/lib/bolt11' import walletDefs from 'wallets/client' import { gql, useApolloClient, useMutation, useQuery } from '@apollo/client' -import { REMOVE_WALLET, WALLET_BY_TYPE, BEST_SEND_WALLETS } from '@/fragments/wallet' +import { REMOVE_WALLET, WALLET_BY_TYPE, BEST_WALLETS } from '@/fragments/wallet' import { autowithdrawInitial } from '@/components/autowithdraw-shared' import { useShowModal } from '@/components/modal' import { useToast } from '../components/toast' @@ -48,7 +48,7 @@ function useWalletInner (name) { const walletDef = getWalletByName(name) const { logger, deleteLogs } = useWalletLogger(walletDef) - const [config, saveConfig, clearConfig] = useConfig(walletDef) + const [config, saveConfig, clearConfig, refreshConfig] = useConfig(walletDef) const available = (!walletDef?.isAvailable || walletDef?.isAvailable()) const status = config?.enabled && available && (config.canSend || config.canReceive) ? Status.Enabled : Status.Initialized @@ -140,7 +140,9 @@ function useWalletInner (name) { wallet.logger = logger wallet.sendPayment = sendPayment wallet.def = walletDef - + wallet.refresh = () => { + return refreshConfig() + } return wallet } @@ -380,7 +382,7 @@ function useConfig (walletDef) { } }, [config, currentWallet]) - return [config, saveConfig, clearConfig] + return [config, saveConfig, clearConfig, refreshConfig] } function generateMutation (wallet) { @@ -463,7 +465,7 @@ export function WalletProvider ({ children }) { const migratableKeys = !migrationRan.current && !SSR ? Object.keys(window.localStorage).filter(k => k.startsWith('wallet:')) : undefined const walletList = walletDefs.map(def => useWalletInner(def.name)).filter(w => w) - const { data: bestSendWalletList } = useQuery(BEST_SEND_WALLETS, SSR + const { data: bestWalletList } = useQuery(BEST_WALLETS, SSR ? {} : { pollInterval: POLL_INTERVAL, @@ -478,11 +480,14 @@ export function WalletProvider ({ children }) { } const wallets = walletList.sort(walletPrioritySort) - const [bestSendWallets, innerSetBestSendWallets] = useState(() => processSendWallets(bestSendWalletList)) + const [bestSendWallets, innerSetBestSendWallets] = useState(() => processSendWallets(bestWalletList)) useEffect(() => { - innerSetBestSendWallets(processSendWallets(bestSendWalletList)) - }, [bestSendWalletList]) + innerSetBestSendWallets(processSendWallets(bestWalletList)) + for (const wallet of wallets) { + wallet.refresh() + } + }, [bestWalletList]) // migration useEffect(() => { From 0263aa83725184b1495ac25b063438ca9f555124 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Wed, 16 Oct 2024 10:17:04 +0200 Subject: [PATCH 20/58] do not test invoice when disabling wallets --- api/resolvers/wallet.js | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 4ec27a8c..11914f76 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -672,7 +672,10 @@ async function upsertWallet ( if (!me) throw new GqlAuthenticationError() assertApiKeyNotPermitted({ me }) - if (testCreateInvoice && !priorityOnly && canReceive) { + const { id, ...walletData } = data + const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, enabled, priority } = settings + + if (testCreateInvoice && !priorityOnly && canReceive && enabled) { try { await testCreateInvoice(data) } catch (err) { @@ -685,15 +688,6 @@ async function upsertWallet ( } } - const { id, ...walletData } = data - const { - autoWithdrawThreshold, - autoWithdrawMaxFeePercent, - autoWithdrawMaxFeeTotal, - enabled, - priority - } = settings - return await models.$transaction(async (tx) => { if (canReceive) { await tx.user.update({ From de0eb8a52c15283e9c3f6c9b749cc8b6ee8654ed Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Wed, 16 Oct 2024 12:48:05 +0200 Subject: [PATCH 21/58] ensure that wallets are configured to send and/or receive --- api/resolvers/wallet.js | 1 + wallets/index.js | 25 ++++++++++++++++--------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 11914f76..35c09057 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -48,6 +48,7 @@ function injectResolvers (resolvers) { } } + if (!canReceive && !canSend) throw new GqlInputError('wallet must be able to send or receive') return await upsertWallet({ wallet: { field: diff --git a/wallets/index.js b/wallets/index.js index 1e1e822f..4cfdefc5 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -313,6 +313,8 @@ function useConfig (walletDef) { } // set server config (will create wallet if it doesn't exist) (it is also testing receive config) + if (!isReadyToSend && !isReadyToReceive) throw new Error('wallet should be configured to send or receive payments') + const mutation = generateMutation(walletDef) const variables = { ...newServerConfig, @@ -321,7 +323,7 @@ function useConfig (walletDef) { autoWithdrawThreshold: Number(autoWithdrawThreshold == null ? autowithdrawSettings.autoWithdrawThreshold : autoWithdrawThreshold), autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent == null ? autowithdrawSettings.autoWithdrawMaxFeePercent : autoWithdrawMaxFeePercent), priority, - enabled: enabled && (isReadyToSend || isReadyToReceive) + enabled }, canSend: isReadyToSend, canReceive: isReadyToReceive, @@ -503,15 +505,20 @@ export function WalletProvider ({ children }) { const userKeys = migratableKeys.filter(k => k.endsWith(`:${userId}`)) ;(async () => { for (const key of userKeys) { - const walletType = key.substring('wallet:'.length, key.length - userId.length - 1) - const walletConfig = JSON.parse(window.localStorage.getItem(key)) - const wallet = wallets.find(w => w.def.name === walletType) - if (wallet) { - console.log('Migrating', walletType, walletConfig) - await wallet.save(walletConfig) + try { + const walletType = key.substring('wallet:'.length, key.length - userId.length - 1) + const walletConfig = JSON.parse(window.localStorage.getItem(key)) + const wallet = wallets.find(w => w.def.name === walletType) + if (wallet) { + console.log('Migrating', walletType, walletConfig) + await wallet.save(walletConfig) + window.localStorage.removeItem(key) + } else { + console.warn('No wallet found for', walletType, wallets) + } + } catch (e) { window.localStorage.removeItem(key) - } else { - console.warn('No wallet found for', walletType, wallets) + console.error('Failed to migrate wallet', key, e) } } })() From 41b86c8251aeada4bf4a1c6560bcb3f228c18a58 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Wed, 16 Oct 2024 12:51:56 +0200 Subject: [PATCH 22/58] unsetLocalKey --- components/use-vault.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/components/use-vault.js b/components/use-vault.js index 7efe861b..5ba47bcd 100644 --- a/components/use-vault.js +++ b/components/use-vault.js @@ -131,6 +131,12 @@ export function useVaultMigration () { return migrate } +export async function unsetLocalKey (userId) { + const config = await openConfig(userId) + await config.unset('key') + await config.close() +} + /** * A react hook to use the vault for a specific owner entity and key * It will automatically handle the vault lifecycle and value updates From 623b69df3ab51cd41ff30269db0b494ef0556ea7 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Wed, 16 Oct 2024 13:06:15 +0200 Subject: [PATCH 23/58] skip wallet fetch for anon users --- wallets/index.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/wallets/index.js b/wallets/index.js index 4cfdefc5..8e505b26 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -197,6 +197,7 @@ function useConfig (walletDef) { }, [walletDef, client]) const refreshConfig = useCallback(async () => { + if (!me?.id) return if (walletDef) { let newConfig = {} newConfig = { @@ -467,12 +468,11 @@ export function WalletProvider ({ children }) { const migratableKeys = !migrationRan.current && !SSR ? Object.keys(window.localStorage).filter(k => k.startsWith('wallet:')) : undefined const walletList = walletDefs.map(def => useWalletInner(def.name)).filter(w => w) - const { data: bestWalletList } = useQuery(BEST_WALLETS, SSR - ? {} - : { - pollInterval: POLL_INTERVAL, - nextFetchPolicy: 'cache-and-network' - }) + const { data: bestWalletList } = useQuery(BEST_WALLETS, { + pollInterval: POLL_INTERVAL, + nextFetchPolicy: 'cache-and-network', + skip: !me?.id + }) const processSendWallets = (bestWalletData) => { const clientSideSorting = false // sorting is now done on the server From 4bc669c1c5a62184ee0dc54109f42bd0b1e2458f Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Wed, 16 Oct 2024 15:33:07 +0200 Subject: [PATCH 24/58] prevent double close --- components/use-local-storage.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/components/use-local-storage.js b/components/use-local-storage.js index c0683bda..8baa841e 100644 --- a/components/use-local-storage.js +++ b/components/use-local-storage.js @@ -30,7 +30,13 @@ export default function useLocalStorage ({ database = 'default', namespace = ['d } }, [me, database, joinedNamespace]) - return [storage] + return [{ + set: storage.set, + get: storage.get, + unset: storage.unset, + clear: storage.clear, + list: storage.list + }] } /** @@ -167,7 +173,7 @@ function newIdxDBBackend (userId, database, namespace) { const queue = createTaskQueue() let openConnection = null - + let closed = false const initialize = async () => { if (!openConnection) { openConnection = await openIdxDB(userId, database, (db) => { @@ -249,6 +255,8 @@ function newIdxDBBackend (userId, database, namespace) { }) }, close: async () => { + if (closed) return + closed = true queue.enqueue(async () => { if (openConnection) await openConnection.close() }) From eeef7039b9b06dd3cf60c5e1b47fe901bb079b50 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Wed, 16 Oct 2024 15:55:00 +0200 Subject: [PATCH 25/58] prevent stale me entry from causing vault configurator to delete the local vault key --- components/use-vault.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/components/use-vault.js b/components/use-vault.js index 5ba47bcd..9211e7cf 100644 --- a/components/use-vault.js +++ b/components/use-vault.js @@ -18,6 +18,7 @@ export function useVaultConfigurator () { const [vaultKey, innerSetVaultKey] = useState(null) const [config, configError] = useConfig() + const [vaultKeyHash, setVaultKeyHashLocal] = useState(null) useEffect(() => { if (!me) return @@ -27,11 +28,12 @@ export function useVaultConfigurator () { } (async () => { let localVaultKey = await config.get('key') - if (localVaultKey && (!me.privates.vaultKeyHash || localVaultKey?.hash !== me.privates.vaultKeyHash)) { + const keyHash = me?.privates?.vaultKeyHash || vaultKeyHash + if ((!keyHash && localVaultKey?.hash) || (localVaultKey?.hash !== keyHash)) { // If the hash stored in the server does not match the hash of the local key, // we can tell that the key is outdated (reset by another device or other reasons) // in this case we clear the local key and let the user re-enter the passphrase - console.log('vault key hash mismatch, clearing local key', localVaultKey, me.privates.vaultKeyHash) + console.log('vault key hash mismatch, clearing local key', localVaultKey?.hash, '!=', keyHash) localVaultKey = null await config.unset('key') } @@ -61,6 +63,7 @@ export function useVaultConfigurator () { } }) innerSetVaultKey(vaultKey) + setVaultKeyHashLocal(vaultKey.hash) await config.set('key', vaultKey) }, [setVaultKeyHash]) @@ -237,7 +240,7 @@ export function openVault (apollo, user, owner) { if ((!user.privates.vaultKeyHash && localVaultKey?.hash) || (localVaultKey?.hash !== user.privates.vaultKeyHash)) { // no or different vault setup on server: use unencrypted local storage // and clear local key if it exists - console.log('Vault key hash mismatch, clearing local key', localVaultKey, user.privates.vaultKeyHash) + console.log('Vault key hash mismatch, clearing local key', localVaultKey?.hash, user.privates.vaultKeyHash) await config.unset('key') return ((await localStore.get(key)) || defaultValue) } From 86994c4c4626a1fa3e6b45cde9c137e2e48bf460 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Wed, 16 Oct 2024 17:11:45 +0200 Subject: [PATCH 26/58] use openConfig instead of useConfig --- components/use-vault.js | 97 ++++++++++++++++++++++++----------------- 1 file changed, 58 insertions(+), 39 deletions(-) diff --git a/components/use-vault.js b/components/use-vault.js index 9211e7cf..1cd3b5c1 100644 --- a/components/use-vault.js +++ b/components/use-vault.js @@ -4,7 +4,7 @@ import { useMutation, useApolloClient } from '@apollo/client' import { SET_ENTRY, UNSET_ENTRY, GET_ENTRY, CLEAR_VAULT, SET_VAULT_KEY_HASH } from '@/fragments/vault' import { E_VAULT_KEY_EXISTS } from '@/lib/error' import { useToast } from '@/components/toast' -import useLocalStorage, { openLocalStorage, listLocalStorages } from '@/components/use-local-storage' +import { openLocalStorage, listLocalStorages } from '@/components/use-local-storage' import { toHex, fromHex } from '@/lib/hex' import createTaskQueue from '@/lib/task-queue' @@ -17,61 +17,84 @@ export function useVaultConfigurator () { const [setVaultKeyHash] = useMutation(SET_VAULT_KEY_HASH) const [vaultKey, innerSetVaultKey] = useState(null) - const [config, configError] = useConfig() const [vaultKeyHash, setVaultKeyHashLocal] = useState(null) useEffect(() => { if (!me) return - if (configError) { - toaster.danger('error loading vault configuration ' + configError.message) - return - } (async () => { - let localVaultKey = await config.get('key') - const keyHash = me?.privates?.vaultKeyHash || vaultKeyHash - if ((!keyHash && localVaultKey?.hash) || (localVaultKey?.hash !== keyHash)) { - // If the hash stored in the server does not match the hash of the local key, - // we can tell that the key is outdated (reset by another device or other reasons) - // in this case we clear the local key and let the user re-enter the passphrase - console.log('vault key hash mismatch, clearing local key', localVaultKey?.hash, '!=', keyHash) - localVaultKey = null - await config.unset('key') + const config = await openConfig(me.id) + try { + let localVaultKey = await config.get('key') + const keyHash = me?.privates?.vaultKeyHash || vaultKeyHash + if ((!keyHash && localVaultKey?.hash) || (localVaultKey?.hash && keyHash && localVaultKey?.hash !== keyHash)) { + // If the hash stored in the server does not match the hash of the local key, + // we can tell that the key is outdated (reset by another device or other reasons) + // in this case we clear the local key and let the user re-enter the passphrase + console.log('vault key hash mismatch, clearing local key', localVaultKey?.hash, '!=', keyHash) + localVaultKey = null + await config.unset('key') + } + innerSetVaultKey(localVaultKey) + } catch (e) { + toaster.danger('error loading vault configuration ' + e.message) + } finally { + await config.close() } - innerSetVaultKey(localVaultKey) })() - }, [me?.privates?.vaultKeyHash, config, configError]) + }, [me?.privates?.vaultKeyHash]) // clear vault: remove everything and reset the key const [clearVault] = useMutation(CLEAR_VAULT, { onCompleted: async () => { - await config.unset('key') - innerSetVaultKey(null) + const config = await openConfig(me.id) + try { + await config.unset('key') + innerSetVaultKey(null) + } catch (e) { + toaster.danger('error clearing vault ' + e.message) + } finally { + await config.close() + } } }) // initialize the vault and set a vault key const setVaultKey = useCallback(async (passphrase) => { - const vaultKey = await deriveKey(me.id, passphrase) - await setVaultKeyHash({ - variables: { hash: vaultKey.hash }, - onError: (error) => { - const errorCode = error.graphQLErrors[0]?.extensions?.code - if (errorCode === E_VAULT_KEY_EXISTS) { - throw new Error('wrong passphrase') + const config = await openConfig(me.id) + try { + const vaultKey = await deriveKey(me.id, passphrase) + await setVaultKeyHash({ + variables: { hash: vaultKey.hash }, + onError: (error) => { + const errorCode = error.graphQLErrors[0]?.extensions?.code + if (errorCode === E_VAULT_KEY_EXISTS) { + throw new Error('wrong passphrase') + } + toaster.danger(error.graphQLErrors[0].message) } - toaster.danger(error.graphQLErrors[0].message) - } - }) - innerSetVaultKey(vaultKey) - setVaultKeyHashLocal(vaultKey.hash) - await config.set('key', vaultKey) + }) + innerSetVaultKey(vaultKey) + setVaultKeyHashLocal(vaultKey.hash) + await config.set('key', vaultKey) + } catch (e) { + toaster.danger('error setting vault key ' + e.message) + } finally { + await config.close() + } }, [setVaultKeyHash]) // disconnect the user from the vault (will not clear or reset the passphrase, use clearVault for that) const disconnectVault = useCallback(async () => { - await config.unset('key') - innerSetVaultKey(null) - }, [innerSetVaultKey, config]) + const config = await openConfig(me.id) + try { + await config.unset('key') + innerSetVaultKey(null) + } catch (e) { + toaster.danger('error disconnecting vault ' + e.message) + } finally { + await config.close() + } + }, [innerSetVaultKey]) return [vaultKey, setVaultKey, clearVault, disconnectVault] } @@ -332,10 +355,6 @@ export function openVault (apollo, user, owner) { return { get: getValue, set: setValue, clear: clearValue, close } } -function useConfig () { - return useLocalStorage({ database: 'vault-config', namespace: ['settings'], supportLegacy: false }) -} - async function openConfig (userId) { return await openLocalStorage({ userId, database: 'vault-config', namespace: ['settings'] }) } From 3acad861575f16630d4492dfe2e426491f80dd67 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Wed, 16 Oct 2024 17:16:17 +0200 Subject: [PATCH 27/58] improve local storage hook implementation --- components/use-local-storage.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/components/use-local-storage.js b/components/use-local-storage.js index 8baa841e..c3171062 100644 --- a/components/use-local-storage.js +++ b/components/use-local-storage.js @@ -1,6 +1,6 @@ import { SSR } from '@/lib/constants' import { useMe } from './me' -import { useEffect, useState } from 'react' +import { useEffect, useRef } from 'react' import createTaskQueue from '@/lib/task-queue' const VERSION = 1 @@ -18,24 +18,24 @@ export default function useLocalStorage ({ database = 'default', namespace = ['d const { me } = useMe() if (!Array.isArray(namespace)) namespace = [namespace] const joinedNamespace = namespace.join(':') - const [storage, setStorage] = useState(openLocalStorage({ database, userId: me?.id, namespace })) + const storage = useRef(openLocalStorage({ database, userId: me?.id, namespace })) useEffect(() => { - const currentStorage = storage + const currentStorage = storage.current const newStorage = openLocalStorage({ database, userId: me?.id, namespace }) - setStorage(newStorage) - if (currentStorage) currentStorage.close() + storage.current = newStorage + if (currentStorage)currentStorage.close() return () => { newStorage.close() } }, [me, database, joinedNamespace]) return [{ - set: storage.set, - get: storage.get, - unset: storage.unset, - clear: storage.clear, - list: storage.list + set: (key, value) => storage.current.set(key, value), + get: (key) => storage.current.get(key), + unset: (key) => storage.current.unset(key), + clear: () => storage.current.clear(), + list: () => storage.current.list() }] } From 00c047f09b4fa04f9cbcaec90eddedb0a0a6b9e7 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 18 Oct 2024 17:58:46 +0200 Subject: [PATCH 28/58] do not drop config on error (might be caused by temporary connection issues) --- wallets/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/wallets/index.js b/wallets/index.js index 8e505b26..0890207e 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -517,7 +517,6 @@ export function WalletProvider ({ children }) { console.warn('No wallet found for', walletType, wallets) } } catch (e) { - window.localStorage.removeItem(key) console.error('Failed to migrate wallet', key, e) } } From fdc3df9c153f9c57f6c02711902f4710c55fb90f Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 18 Oct 2024 18:40:06 +0200 Subject: [PATCH 29/58] force refresh client wallets when device sync is enabled --- components/device-sync.js | 5 +++++ fragments/wallet.js | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/components/device-sync.js b/components/device-sync.js index 73818aff..67273d3c 100644 --- a/components/device-sync.js +++ b/components/device-sync.js @@ -10,9 +10,11 @@ import CancelButton from './cancel-button' import * as yup from 'yup' import { deviceSyncSchema } from '@/lib/validate' import RefreshIcon from '@/svgs/refresh-line.svg' +import { useApolloClient } from '@apollo/client' export default function DeviceSync () { const { me } = useMe() + const apollo = useApolloClient() const [value, setVaultKey, clearVault, disconnectVault] = useVaultConfigurator() const showModal = useShowModal() @@ -105,6 +107,9 @@ export default function DeviceSync () { try { await setVaultKey(values.passphrase) await migrate() + apollo.cache.evict({ fieldName: 'BestWallets' }) + apollo.cache.gc() + await apollo.refetchQueries({ include: ['BestWallets'] }) } catch (e) { formik?.setErrors({ passphrase: e.message }) throw e diff --git a/fragments/wallet.js b/fragments/wallet.js index af8ba5a1..b5a0e820 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -209,7 +209,7 @@ export const BEST_WALLETS = gql` } ` export const BEST_SEND_WALLETS = gql` - query BestWallets { + query BestSendWallets { wallets (includeSenders: true, includeReceivers: false, onlyEnabled: true, prioritySort: "asc") { id priority From ed66cfb3f842b461cd932c889ebfc0a2a5959dbd Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Fri, 18 Oct 2024 18:50:42 +0200 Subject: [PATCH 30/58] remove unused fragment, fix BestWallet to return flags --- fragments/wallet.js | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/fragments/wallet.js b/fragments/wallet.js index b5a0e820..e5b4b226 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -205,16 +205,9 @@ export const BEST_WALLETS = gql` priority type updatedAt - } - } -` -export const BEST_SEND_WALLETS = gql` - query BestSendWallets { - wallets (includeSenders: true, includeReceivers: false, onlyEnabled: true, prioritySort: "asc") { - id - priority - type - updatedAt + canSend + canReceive + enabled } } ` From 6a23aac6f92555cd3287672e864c67d340448f28 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Sun, 20 Oct 2024 09:38:04 +0200 Subject: [PATCH 31/58] Update api/resolvers/vault.js --- api/resolvers/vault.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/resolvers/vault.js b/api/resolvers/vault.js index 6c13eda2..279dd933 100644 --- a/api/resolvers/vault.js +++ b/api/resolvers/vault.js @@ -141,7 +141,7 @@ export default { */ function checkOwner (info, ownerType) { const gqltypeDef = info.schema.getType(ownerType) - const ownerInterfaces = gqltypeDef?.getInterfaces ? gqltypeDef.getInterfaces() : null + const ownerInterfaces = gqltypeDef?.getInterfaces?.() if (!ownerInterfaces?.some((iface) => iface.name === 'VaultOwner')) { throw new GqlInputError('owner must implement VaultOwner interface but ' + ownerType + ' does not') } From 319db6dea6508f48372deac7be834f2d335a3826 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Sun, 20 Oct 2024 09:38:27 +0200 Subject: [PATCH 32/58] fix schema --- prisma/migrations/20241011131732_client_wallets/migration.sql | 4 ++-- prisma/schema.prisma | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/prisma/migrations/20241011131732_client_wallets/migration.sql b/prisma/migrations/20241011131732_client_wallets/migration.sql index f2e27efc..216e2d59 100644 --- a/prisma/migrations/20241011131732_client_wallets/migration.sql +++ b/prisma/migrations/20241011131732_client_wallets/migration.sql @@ -11,8 +11,8 @@ ALTER TYPE "WalletType" ADD VALUE 'LNC'; ALTER TYPE "WalletType" ADD VALUE 'WEBLN'; -- AlterTable -ALTER TABLE "Wallet" ADD COLUMN "canReceive" BOOLEAN NOT NULL DEFAULT false, -ADD COLUMN "canSend" BOOLEAN NOT NULL DEFAULT true; +ALTER TABLE "Wallet" ADD COLUMN "canReceive" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "canSend" BOOLEAN NOT NULL DEFAULT false; -- CreateTable CREATE TABLE "WalletWebLn" ( diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f4ab076b..2884df91 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -195,8 +195,8 @@ model Wallet { enabled Boolean @default(true) priority Int @default(0) user User @relation(fields: [userId], references: [id], onDelete: Cascade) - canReceive Boolean @default(false) - canSend Boolean @default(true) + canReceive Boolean @default(true) + canSend Boolean @default(false) // NOTE: this denormalized json field exists to make polymorphic joins efficient // when reading wallets ... it is populated by a trigger when wallet descendants update From 2cfe851046555c0045ee02b9535a6e316e11a87e Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Sun, 20 Oct 2024 09:42:26 +0200 Subject: [PATCH 33/58] merge migrations --- .../migration.sql | 48 ------------------- .../migration.sql | 25 ---------- 2 files changed, 73 deletions(-) delete mode 100644 prisma/migrations/20241013200637_client_wallets_2/migration.sql diff --git a/prisma/migrations/20241011131732_client_wallets/migration.sql b/prisma/migrations/20241011131732_client_wallets/migration.sql index 216e2d59..5ef68626 100644 --- a/prisma/migrations/20241011131732_client_wallets/migration.sql +++ b/prisma/migrations/20241011131732_client_wallets/migration.sql @@ -13,51 +13,3 @@ 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; - --- CreateTable -CREATE TABLE "WalletWebLn" ( - "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, - - CONSTRAINT "WalletWebLn_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "WalletLNC" ( - "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, - - CONSTRAINT "WalletLNC_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "WalletBlink" ( - "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, - - CONSTRAINT "WalletBlink_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "WalletWebLn_walletId_key" ON "WalletWebLn"("walletId"); - --- CreateIndex -CREATE UNIQUE INDEX "WalletLNC_walletId_key" ON "WalletLNC"("walletId"); - --- CreateIndex -CREATE UNIQUE INDEX "WalletBlink_walletId_key" ON "WalletBlink"("walletId"); - --- AddForeignKey -ALTER TABLE "WalletWebLn" ADD CONSTRAINT "WalletWebLn_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "WalletLNC" ADD CONSTRAINT "WalletLNC_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "WalletBlink" ADD CONSTRAINT "WalletBlink_walletId_fkey" FOREIGN KEY ("walletId") REFERENCES "Wallet"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20241013200637_client_wallets_2/migration.sql b/prisma/migrations/20241013200637_client_wallets_2/migration.sql deleted file mode 100644 index 593c9b2e..00000000 --- a/prisma/migrations/20241013200637_client_wallets_2/migration.sql +++ /dev/null @@ -1,25 +0,0 @@ -/* - Warnings: - - - You are about to drop the `WalletBlink` table. If the table is not empty, all the data it contains will be lost. - - You are about to drop the `WalletLNC` table. If the table is not empty, all the data it contains will be lost. - - You are about to drop the `WalletWebLn` table. If the table is not empty, all the data it contains will be lost. - -*/ --- DropForeignKey -ALTER TABLE "WalletBlink" DROP CONSTRAINT "WalletBlink_walletId_fkey"; - --- DropForeignKey -ALTER TABLE "WalletLNC" DROP CONSTRAINT "WalletLNC_walletId_fkey"; - --- DropForeignKey -ALTER TABLE "WalletWebLn" DROP CONSTRAINT "WalletWebLn_walletId_fkey"; - --- DropTable -DROP TABLE "WalletBlink"; - --- DropTable -DROP TABLE "WalletLNC"; - --- DropTable -DROP TABLE "WalletWebLn"; From 1e68182cda9029b714d3e7620bd2102fee9fa180 Mon Sep 17 00:00:00 2001 From: Riccardo Balbo Date: Sun, 20 Oct 2024 17:36:55 +0200 Subject: [PATCH 34/58] sender wallets: only test if enabled --- wallets/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wallets/index.js b/wallets/index.js index 0890207e..07fce375 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -300,7 +300,7 @@ function useConfig (walletDef) { // console.log('Sender', isReadyToSend, 'Receiver', isReadyToReceive, 'enabled', enabled, autoWithdrawThreshold, autoWithdrawMaxFeePercent, priority) // client test - if (!skipTests && isReadyToSend) { + if (!skipTests && isReadyToSend && enabled) { try { // XXX: testSendPayment can return a new config (e.g. lnc) const newerConfig = await walletDef.testSendPayment?.(newClientConfig, { me, logger }) From 4e61c19bb3c06f035a3121402a3201a578d629a4 Mon Sep 17 00:00:00 2001 From: k00b Date: Mon, 21 Oct 2024 11:46:43 -0500 Subject: [PATCH 35/58] fix showing autowithdraw settings on send only wallets --- pages/settings/wallets/[wallet].js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pages/settings/wallets/[wallet].js b/pages/settings/wallets/[wallet].js index 08f6203d..c40ba65c 100644 --- a/pages/settings/wallets/[wallet].js +++ b/pages/settings/wallets/[wallet].js @@ -68,9 +68,8 @@ export default function WalletSettings () { }} > {wallet && } - {wallet?.walletType - ? - : ( + {wallet?.clientOnly + ? ( - )} + ) + : } { try { From b61c957cc7b12a5ba5c4d3cff1f5611cb74e7f0e Mon Sep 17 00:00:00 2001 From: k00b Date: Mon, 21 Oct 2024 11:49:16 -0500 Subject: [PATCH 36/58] fix missing field from merge conflict resolution --- wallets/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/wallets/index.js b/wallets/index.js index 07fce375..b0079594 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -293,7 +293,7 @@ function useConfig (walletDef) { // check if it misses send or receive configs const isReadyToSend = canSend && isConfigured({ fields: walletDef.fields, config: newConfig, clientOnly: true }) const isReadyToReceive = canReceive && isConfigured({ fields: walletDef.fields, config: newConfig, serverOnly: true }) - const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, priority, enabled } = newConfig + const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, autoWithdrawMaxFeeTotal, priority, enabled } = newConfig // console.log('New client config', newClientConfig) // console.log('New server config', newServerConfig) @@ -323,6 +323,7 @@ function useConfig (walletDef) { settings: { autoWithdrawThreshold: Number(autoWithdrawThreshold == null ? autowithdrawSettings.autoWithdrawThreshold : autoWithdrawThreshold), autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent == null ? autowithdrawSettings.autoWithdrawMaxFeePercent : autoWithdrawMaxFeePercent), + autoWithdrawMaxFeeTotal: Number(autoWithdrawMaxFeeTotal == null ? autowithdrawSettings.autoWithdrawMaxFeeTotal : autoWithdrawMaxFeeTotal), priority, enabled }, From da020cf899f8f3001dc4c6eb5ab6d5e140a3d2f6 Mon Sep 17 00:00:00 2001 From: k00b Date: Tue, 22 Oct 2024 19:53:56 -0500 Subject: [PATCH 37/58] complete fantasy scaffolding --- api/resolvers/vault.js | 126 +--- api/resolvers/wallet.js | 38 +- api/typeDefs/user.js | 2 +- api/typeDefs/vault.js | 21 +- api/typeDefs/wallet.js | 3 +- components/device-sync.js | 2 +- components/item-act.js | 2 +- components/nav/common.js | 2 +- components/payment.js | 2 +- components/qr.js | 2 +- components/use-indexeddb.js | 23 +- components/use-vault.js | 453 ------------- components/vault/use-vault-configurator.js | 145 +++++ components/vault/use-vault.js | 64 ++ components/wallet-card.js | 2 +- components/wallet-logger.js | 12 +- fragments/users.js | 6 - fragments/vault.js | 45 +- fragments/wallet.js | 20 +- lib/wallet.js | 62 -- pages/_app.js | 2 +- pages/settings/wallets/[wallet].js | 2 +- pages/settings/wallets/index.js | 2 +- .../20241011131443_vault/migration.sql | 28 - .../migration.sql | 15 - .../20241021224248_vault/migration.sql | 46 ++ prisma/schema.prisma | 56 +- wallets/common.js | 47 ++ wallets/config.js | 164 +++++ wallets/graphql.js | 59 ++ wallets/index.js | 599 +++--------------- 31 files changed, 747 insertions(+), 1305 deletions(-) delete mode 100644 components/use-vault.js create mode 100644 components/vault/use-vault-configurator.js create mode 100644 components/vault/use-vault.js delete mode 100644 lib/wallet.js delete mode 100644 prisma/migrations/20241011131443_vault/migration.sql delete mode 100644 prisma/migrations/20241011131732_client_wallets/migration.sql create mode 100644 prisma/migrations/20241021224248_vault/migration.sql create mode 100644 wallets/common.js create mode 100644 wallets/config.js create mode 100644 wallets/graphql.js diff --git a/api/resolvers/vault.js b/api/resolvers/vault.js index 279dd933..ecb8ec59 100644 --- a/api/resolvers/vault.js +++ b/api/resolvers/vault.js @@ -1,36 +1,27 @@ import { E_VAULT_KEY_EXISTS, GqlAuthenticationError, GqlInputError } from '@/lib/error' export default { - VaultOwner: { - __resolveType: (obj) => obj.type - }, Query: { - getVaultEntry: async (parent, { ownerId, ownerType, key }, { me, models }, info) => { + getVaultEntry: async (parent, { key }, { me, models }, info) => { if (!me) throw new GqlAuthenticationError() if (!key) throw new GqlInputError('must have key') - checkOwner(info, ownerType) const k = await models.vault.findUnique({ where: { userId_key_ownerId_ownerType: { key, - userId: me.id, - ownerId: Number(ownerId), - ownerType + userId: me.id } } }) return k }, - getVaultEntries: async (parent, { ownerId, ownerType, keysFilter }, { me, models }, info) => { + getVaultEntries: async (parent, { keysFilter }, { me, models }, info) => { if (!me) throw new GqlAuthenticationError() - checkOwner(info, ownerType) const entries = await models.vault.findMany({ where: { userId: me.id, - ownerId: Number(ownerId), - ownerType, key: keysFilter?.length ? { in: keysFilter @@ -42,77 +33,11 @@ export default { } }, Mutation: { - setVaultEntry: async (parent, { ownerId, ownerType, key, value, skipIfSet }, { me, models }, info) => { - if (!me) throw new GqlAuthenticationError() - if (!key) throw new GqlInputError('must have key') - if (!value) throw new GqlInputError('must have value') - checkOwner(info, ownerType) - - if (skipIfSet) { - const existing = await models.vault.findUnique({ - where: { - userId_key_ownerId_ownerType: { - userId: me.id, - key, - ownerId: Number(ownerId), - ownerType - } - } - }) - if (existing) { - return false - } - } - await models.vault.upsert({ - where: { - userId_key_ownerId_ownerType: { - userId: me.id, - key, - ownerId: Number(ownerId), - ownerType - } - }, - update: { - value - }, - create: { - key, - value, - userId: me.id, - ownerId: Number(ownerId), - ownerType - } - }) - return true - }, - unsetVaultEntry: async (parent, { ownerId, ownerType, key }, { me, models }, info) => { - if (!me) throw new GqlAuthenticationError() - if (!key) throw new GqlInputError('must have key') - checkOwner(info, ownerType) - - await models.vault.deleteMany({ - where: { - userId: me.id, - key, - ownerId: Number(ownerId), - ownerType - } - }) - return true - }, - clearVault: async (parent, args, { me, models }) => { - if (!me) throw new GqlAuthenticationError() - - await models.user.update({ - where: { id: me.id }, - data: { vaultKeyHash: '' } - }) - await models.vault.deleteMany({ where: { userId: me.id } }) - return true - }, - setVaultKeyHash: async (parent, { hash }, { me, models }) => { + // atomic vault migration + updateVaultKey: async (parent, { entries, hash }, { me, models }) => { if (!me) throw new GqlAuthenticationError() if (!hash) throw new GqlInputError('hash required') + const txs = [] const { vaultKeyHash: oldKeyHash } = await models.user.findUnique({ where: { id: me.id } }) if (oldKeyHash) { @@ -122,27 +47,32 @@ export default { return true } } else { - await models.user.update({ + txs.push(models.user.update({ where: { id: me.id }, data: { vaultKeyHash: hash } - }) + })) } + + for (const entry of entries) { + txs.push(models.vaultEntry.upsert({ + where: { userId: me.id, key: entry.key }, + update: { key: entry.key, value: entry.value }, + create: { key: entry.key, value: entry.value, userId: me.id, walletId: entry.walletId } + })) + } + await models.prisma.$transaction(txs) + return true + }, + clearVault: async (parent, args, { me, models }) => { + if (!me) throw new GqlAuthenticationError() + const txs = [] + txs.push(models.user.update({ + where: { id: me.id }, + data: { vaultKeyHash: '' } + })) + txs.push(models.vaultEntry.deleteMany({ where: { userId: me.id } })) + await models.prisma.$transaction(txs) return true } } } - -/** - * Ensures the passed ownerType represent a valid type that extends VaultOwner in the graphql schema. - * Throws a GqlInputError otherwise - * @param {*} info the graphql resolve info - * @param {string} ownerType the ownerType to check - * @throws GqlInputError - */ -function checkOwner (info, ownerType) { - const gqltypeDef = info.schema.getType(ownerType) - const ownerInterfaces = gqltypeDef?.getInterfaces?.() - if (!ownerInterfaces?.some((iface) => iface.name === 'VaultOwner')) { - throw new GqlInputError('owner must implement VaultOwner interface but ' + ownerType + ' does not') - } -} diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 35c09057..aecd05fd 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -161,6 +161,9 @@ const resolvers = { where: { userId: me.id, id: Number(id) + }, + include: { + vaultEntries: true } }) }, @@ -173,40 +176,29 @@ const resolvers = { where: { userId: me.id, type + }, + include: { + vaultEntries: true } }) return wallet }, - wallets: async (parent, { includeReceivers = true, includeSenders = true, onlyEnabled = false, prioritySort = undefined }, { me, models }) => { + wallets: async (parent, args, { me, models }) => { if (!me) { throw new GqlAuthenticationError() } - const filter = { - userId: me.id - } - - if (includeReceivers && includeSenders) { - filter.OR = [ - { canReceive: true }, - { canSend: true } - ] - } else if (includeReceivers) { - filter.canReceive = true - } else if (includeSenders) { - filter.canSend = true - } - if (onlyEnabled) { - filter.enabled = true - } - - const out = await models.wallet.findMany({ - where: filter, + return await models.wallet.findMany({ + include: { + vaultEntries: true + }, + where: { + userId: me.id + }, orderBy: { - priority: prioritySort + priority: 'asc' } }) - return out }, withdrawl: getWithdrawl, numBolt11s: async (parent, args, { me, models, lnd }) => { diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index ca14e011..cfbfd98e 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -46,7 +46,7 @@ export default gql` disableFreebies: Boolean } - type User implements VaultOwner { + type User { id: ID! createdAt: Date! name: String diff --git a/api/typeDefs/vault.js b/api/typeDefs/vault.js index 0e8efd22..a1600ea9 100644 --- a/api/typeDefs/vault.js +++ b/api/typeDefs/vault.js @@ -1,11 +1,7 @@ import { gql } from 'graphql-tag' export default gql` - interface VaultOwner { - id: ID! - } - - type Vault { + type VaultEntry { id: ID! key: String! value: String! @@ -13,16 +9,19 @@ export default gql` updatedAt: Date! } + input VaultEntryInput { + key: String! + value: String! + walletId: ID + } + extend type Query { - getVaultEntry(ownerId:ID!, ownerType:String!, key: String!): Vault - getVaultEntries(ownerId:ID!, ownerType:String!, keysFilter: [String]): [Vault!]! + getVaultEntry(key: String!): VaultEntry + getVaultEntries(keysFilter: [String!]): [VaultEntry!]! } extend type Mutation { - setVaultEntry(ownerId:ID!, ownerType:String!, key: String!, value: String!, skipIfSet: Boolean): Boolean - unsetVaultEntry(ownerId:ID!, ownerType:String!, key: String!): Boolean - clearVault: Boolean - setVaultKeyHash(hash: String!): String + updateVaultKey(entries: [VaultEntryInput!]!, hash: String!): Boolean } ` diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index 86b1d559..406b8891 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -83,7 +83,7 @@ const typeDefs = ` deleteWalletLogs(wallet: String): Boolean } - type Wallet implements VaultOwner { + type Wallet { id: ID! createdAt: Date! updatedAt: Date! @@ -93,6 +93,7 @@ const typeDefs = ` wallet: WalletDetails! canReceive: Boolean! canSend: Boolean! + vaultEntries: [VaultEntry!]! } input AutowithdrawSettings { diff --git a/components/device-sync.js b/components/device-sync.js index 67273d3c..c948d84e 100644 --- a/components/device-sync.js +++ b/components/device-sync.js @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import { useMe } from './me' import { useShowModal } from './modal' -import useVault, { useVaultConfigurator, useVaultMigration } from './use-vault' +import useVault, { useVaultConfigurator, useVaultMigration } from './vault/use-vault' import { Button, InputGroup } from 'react-bootstrap' import { Form, Input, PasswordInput, SubmitButton } from './form' import bip39Words from '@/lib/bip39-words' diff --git a/components/item-act.js b/components/item-act.js index 9e38fcfb..36eaeaf7 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -13,7 +13,7 @@ import { usePaidMutation } from './use-paid-mutation' import { ACT_MUTATION } from '@/fragments/paidAction' import { meAnonSats } from '@/lib/apollo' import { BoostItemInput } from './adv-post-form' -import { useWallet } from '../wallets' +import { useWallet } from '../wallets/common' const defaultTips = [100, 1000, 10_000, 100_000] diff --git a/components/nav/common.js b/components/nav/common.js index d42bca06..184c199a 100644 --- a/components/nav/common.js +++ b/components/nav/common.js @@ -22,7 +22,7 @@ import SearchIcon from '../../svgs/search-line.svg' import classNames from 'classnames' import SnIcon from '@/svgs/sn.svg' import { useHasNewNotes } from '../use-has-new-notes' -import { useWallets } from 'wallets' +import { useWallets } from '@/wallets/common' import SwitchAccountList, { useAccounts } from '@/components/account' import { useShowModal } from '@/components/modal' import { unsetLocalKey as resetVaultKey } from '@/components/use-vault' diff --git a/components/payment.js b/components/payment.js index 253cc6df..f5eb6e67 100644 --- a/components/payment.js +++ b/components/payment.js @@ -1,7 +1,7 @@ import { useCallback, useMemo } from 'react' import { useMe } from './me' import { gql, useApolloClient, useMutation } from '@apollo/client' -import { useWallet } from 'wallets' +import { useWallet } from '@/wallets/common' import { FAST_POLL_INTERVAL, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants' import { INVOICE } from '@/fragments/wallet' import Invoice from '@/components/invoice' diff --git a/components/qr.js b/components/qr.js index 10cf5fcb..12bf5a93 100644 --- a/components/qr.js +++ b/components/qr.js @@ -2,7 +2,7 @@ import { QRCodeSVG } from 'qrcode.react' import { CopyInput, InputSkeleton } from './form' import InvoiceStatus from './invoice-status' import { useEffect } from 'react' -import { useWallet } from 'wallets' +import { useWallet } from '@/wallets/common' import Bolt11Info from './bolt11-info' export default function Qr ({ asIs, value, useWallet: automated, statusVariant, description, status }) { diff --git a/components/use-indexeddb.js b/components/use-indexeddb.js index d4f651b3..948440a4 100644 --- a/components/use-indexeddb.js +++ b/components/use-indexeddb.js @@ -1,6 +1,10 @@ import { useState, useEffect, useCallback, useRef } from 'react' -function useIndexedDB (dbName, storeName, version = 1, indices = []) { +export function getDbName (userId) { + return `app:storage${userId ? `:${userId}` : ''}` +} + +function useIndexedDB ({ dbName, storeName, options = { keyPath: 'id', autoIncrement: true }, indices = [], version = 1 }) { const [db, setDb] = useState(null) const [error, setError] = useState(null) const [notSupported, setNotSupported] = useState(false) @@ -58,7 +62,7 @@ function useIndexedDB (dbName, storeName, version = 1, indices = []) { request.onupgradeneeded = (event) => { const database = event.target.result try { - const store = database.createObjectStore(storeName, { keyPath: 'id', autoIncrement: true }) + const store = database.createObjectStore(storeName, options) indices.forEach(index => { store.createIndex(index.name, index.keyPath, index.options) @@ -141,20 +145,15 @@ function useIndexedDB (dbName, storeName, version = 1, indices = []) { }) }, [queueOperation, storeName]) - const update = useCallback((key, value) => { + const set = useCallback((key, value) => { return queueOperation((db) => { return new Promise((resolve, reject) => { const transaction = db.transaction(storeName, 'readwrite') const store = transaction.objectStore(storeName) - const request = store.get(key) + const request = store.put(value, key) - request.onerror = () => reject(new Error('Error updating data')) - request.onsuccess = () => { - const updatedValue = { ...request.result, ...value } - const updateRequest = store.put(updatedValue) - updateRequest.onerror = () => reject(new Error('Error updating data')) - updateRequest.onsuccess = () => resolve(updateRequest.result) - } + request.onerror = () => reject(new Error('Error setting data')) + request.onsuccess = () => resolve(request.result) }) }) }, [queueOperation, storeName]) @@ -286,7 +285,7 @@ function useIndexedDB (dbName, storeName, version = 1, indices = []) { }) }, [queueOperation, storeName]) - return { add, get, getAll, update, remove, clear, getByIndex, getAllByIndex, getPage, error, notSupported } + return { add, get, getAll, set, remove, clear, getByIndex, getAllByIndex, getPage, error, notSupported } } export default useIndexedDB diff --git a/components/use-vault.js b/components/use-vault.js deleted file mode 100644 index 1cd3b5c1..00000000 --- a/components/use-vault.js +++ /dev/null @@ -1,453 +0,0 @@ -import { useCallback, useState, useEffect, useRef } from 'react' -import { useMe } from '@/components/me' -import { useMutation, useApolloClient } from '@apollo/client' -import { SET_ENTRY, UNSET_ENTRY, GET_ENTRY, CLEAR_VAULT, SET_VAULT_KEY_HASH } from '@/fragments/vault' -import { E_VAULT_KEY_EXISTS } from '@/lib/error' -import { useToast } from '@/components/toast' -import { openLocalStorage, listLocalStorages } from '@/components/use-local-storage' -import { toHex, fromHex } from '@/lib/hex' -import createTaskQueue from '@/lib/task-queue' - -/** - * A react hook to configure the vault for the current user - */ -export function useVaultConfigurator () { - const { me } = useMe() - const toaster = useToast() - const [setVaultKeyHash] = useMutation(SET_VAULT_KEY_HASH) - - const [vaultKey, innerSetVaultKey] = useState(null) - const [vaultKeyHash, setVaultKeyHashLocal] = useState(null) - - useEffect(() => { - if (!me) return - (async () => { - const config = await openConfig(me.id) - try { - let localVaultKey = await config.get('key') - const keyHash = me?.privates?.vaultKeyHash || vaultKeyHash - if ((!keyHash && localVaultKey?.hash) || (localVaultKey?.hash && keyHash && localVaultKey?.hash !== keyHash)) { - // If the hash stored in the server does not match the hash of the local key, - // we can tell that the key is outdated (reset by another device or other reasons) - // in this case we clear the local key and let the user re-enter the passphrase - console.log('vault key hash mismatch, clearing local key', localVaultKey?.hash, '!=', keyHash) - localVaultKey = null - await config.unset('key') - } - innerSetVaultKey(localVaultKey) - } catch (e) { - toaster.danger('error loading vault configuration ' + e.message) - } finally { - await config.close() - } - })() - }, [me?.privates?.vaultKeyHash]) - - // clear vault: remove everything and reset the key - const [clearVault] = useMutation(CLEAR_VAULT, { - onCompleted: async () => { - const config = await openConfig(me.id) - try { - await config.unset('key') - innerSetVaultKey(null) - } catch (e) { - toaster.danger('error clearing vault ' + e.message) - } finally { - await config.close() - } - } - }) - - // initialize the vault and set a vault key - const setVaultKey = useCallback(async (passphrase) => { - const config = await openConfig(me.id) - try { - const vaultKey = await deriveKey(me.id, passphrase) - await setVaultKeyHash({ - variables: { hash: vaultKey.hash }, - onError: (error) => { - const errorCode = error.graphQLErrors[0]?.extensions?.code - if (errorCode === E_VAULT_KEY_EXISTS) { - throw new Error('wrong passphrase') - } - toaster.danger(error.graphQLErrors[0].message) - } - }) - innerSetVaultKey(vaultKey) - setVaultKeyHashLocal(vaultKey.hash) - await config.set('key', vaultKey) - } catch (e) { - toaster.danger('error setting vault key ' + e.message) - } finally { - await config.close() - } - }, [setVaultKeyHash]) - - // disconnect the user from the vault (will not clear or reset the passphrase, use clearVault for that) - const disconnectVault = useCallback(async () => { - const config = await openConfig(me.id) - try { - await config.unset('key') - innerSetVaultKey(null) - } catch (e) { - toaster.danger('error disconnecting vault ' + e.message) - } finally { - await config.close() - } - }, [innerSetVaultKey]) - - return [vaultKey, setVaultKey, clearVault, disconnectVault] -} - -/** - * A react hook to migrate local vault storage to the synched vault - */ -export function useVaultMigration () { - const { me } = useMe() - const apollo = useApolloClient() - // migrate local storage to vault - const migrate = useCallback(async () => { - let migratedCount = 0 - const config = await openConfig(me?.id) - const vaultKey = await config.get('key') - if (!vaultKey) throw new Error('vault key not found') - // we collect all the storages used by the vault - const namespaces = await listLocalStorages({ userId: me?.id, database: 'vault', supportLegacy: true }) - for (const namespace of namespaces) { - // we open every one of them and copy the entries to the vault - const storage = await openLocalStorage({ userId: me?.id, database: 'vault', namespace, supportLegacy: true }) - const entryNames = await storage.list() - for (const entryName of entryNames) { - try { - const value = await storage.get(entryName) - if (!value) throw new Error('no value found in local storage') - // (we know the layout we use for vault entries) - const type = namespace[0] - const id = namespace[1] - if (!type || !id || isNaN(id)) throw new Error('unknown vault namespace layout') - // encrypt and store on the server - const encrypted = await encryptData(vaultKey.key, value) - const { data } = await apollo.mutate({ - mutation: SET_ENTRY, - variables: { - key: entryName, - value: encrypted, - skipIfSet: true, - ownerType: type, - ownerId: Number(id) - } - }) - if (data?.setVaultEntry) { - // clear local storage - await storage.unset(entryName) - migratedCount++ - console.log('migrated to vault:', entryName) - } else { - console.log('could not set vault entry:', entryName) - } - } catch (e) { - console.error('failed migrate to vault:', entryName, e) - } - } - await storage.close() - } - return migratedCount - }, [me?.id]) - - return migrate -} - -export async function unsetLocalKey (userId) { - const config = await openConfig(userId) - await config.unset('key') - await config.close() -} - -/** - * A react hook to use the vault for a specific owner entity and key - * It will automatically handle the vault lifecycle and value updates - * @param {*} owner - the owner entity with id and type or __typename (must extend VaultOwner in the graphql schema) - * @param {*} key - the key to store and retrieve the value - * @param {*} defaultValue - the default value to return when no value is found - * - * @returns {Array} - An array containing: - * @returns {any} 0 - The current value stored in the vault. - * @returns {function(any): Promise} 1 - A function to set a new value in the vault. - * @returns {function({onlyFromLocalStorage?: boolean}): Promise} 2 - A function to clear the value in the vault. - * @returns {function(): Promise} 3 - A function to refresh the value from the vault. - */ -export default function useVault (owner, key, defaultValue) { - const { me } = useMe() - const toaster = useToast() - const apollo = useApolloClient() - - const [value, innerSetValue] = useState(undefined) - const vault = useRef(openVault(apollo, me, owner)) - - const setValue = useCallback(async (newValue) => { - innerSetValue(newValue) - return vault.current.set(key, newValue) - }, [key]) - - const clearValue = useCallback(async ({ onlyFromLocalStorage = false } = {}) => { - innerSetValue(defaultValue) - return vault.current.clear(key, { onlyFromLocalStorage }) - }, [key, defaultValue]) - - const refreshData = useCallback(async () => { - innerSetValue(await vault.current.get(key)) - }, [key]) - - useEffect(() => { - const currentVault = vault.current - const newVault = openVault(apollo, me, owner) - vault.current = newVault - if (currentVault)currentVault.close() - refreshData().catch(e => toaster.danger('failed to refresh vault data: ' + e.message)) - return () => { - newVault.close() - } - }, [me, owner, key]) - - return [value, setValue, clearValue, refreshData] -} - -/** - * Open the vault for the given user and owner entry - * @param {*} apollo - the apollo client - * @param {*} user - the user entry with id and privates.vaultKeyHash - * @param {*} owner - the owner entry with id and type or __typename (must extend VaultOwner in the graphql schema) - * - * @returns {Object} - An object containing: - * @returns {function(string, any): Promise} get - A function to get a value from the vault. - * @returns {function(string, any): Promise} set - A function to set a new value in the vault. - * @returns {function(string, {onlyFromLocalStorage?: boolean}): Promise} clear - A function to clear a value in the vault. - * @returns {function(): Promise} refresh - A function to refresh the value from the vault. - */ -export function openVault (apollo, user, owner) { - const userId = user?.id - const type = owner?.__typename || owner?.type - const id = owner?.id - - const localOnly = !userId - - let config = null - let localStore = null - const queue = createTaskQueue() - - const waitInitialization = async () => { - if (!config) { - config = await openConfig(userId) - } - if (!localStore) { - localStore = type && id ? await openLocalStorage({ userId, database: localOnly ? 'local-vault' : 'vault', namespace: [type, id] }) : null - } - } - - const getValue = async (key, defaultValue) => { - return await queue.enqueue(async () => { - await waitInitialization() - if (!localStore) return undefined - - if (localOnly) { - // local only: we fetch from local storage and return - return ((await localStore.get(key)) || defaultValue) - } - - const localVaultKey = await config.get('key') - if (!localVaultKey?.hash) { - // no vault key set: use local storage - return ((await localStore.get(key)) || defaultValue) - } - - if ((!user.privates.vaultKeyHash && localVaultKey?.hash) || (localVaultKey?.hash !== user.privates.vaultKeyHash)) { - // no or different vault setup on server: use unencrypted local storage - // and clear local key if it exists - console.log('Vault key hash mismatch, clearing local key', localVaultKey?.hash, user.privates.vaultKeyHash) - await config.unset('key') - return ((await localStore.get(key)) || defaultValue) - } - - // if vault key hash is set on the server and matches our local key, we try to fetch from the vault - { - const { data: queriedData, error: queriedError } = await apollo.query({ - query: GET_ENTRY, - variables: { key, ownerId: id, ownerType: type }, - nextFetchPolicy: 'no-cache', - fetchPolicy: 'no-cache' - }) - if (queriedError) throw queriedError - const encryptedVaultValue = queriedData?.getVaultEntry?.value - if (encryptedVaultValue) { - try { - const vaultValue = await decryptData(localVaultKey.key, encryptedVaultValue) - // console.log('decrypted value from vault:', storageKey, encrypted, decrypted) - // remove local storage value if it exists - await localStore.unset(key) - return vaultValue - } catch (e) { - console.error('cannot read vault data:', key, e) - } - } - } - - // fallback to local storage - return ((await localStore.get(key)) || defaultValue) - }) - } - - const setValue = async (key, newValue) => { - return await queue.enqueue(async () => { - await waitInitialization() - - if (!localStore) { - return - } - const vaultKey = await config.get('key') - - const useVault = vaultKey && vaultKey.hash === user.privates.vaultKeyHash - - if (useVault && !localOnly) { - const encryptedValue = await encryptData(vaultKey.key, newValue) - console.log('store encrypted value in vault:', key) - await apollo.mutate({ - mutation: SET_ENTRY, - variables: { key, value: encryptedValue, ownerId: id, ownerType: type } - }) - // clear local storage (we get rid of stored unencrypted data as soon as it can be stored on the vault) - await localStore.unset(key) - } else { - console.log('store value in local storage:', key) - // otherwise use local storage - await localStore.set(key, newValue) - } - }) - } - - const clearValue = async (key, { onlyFromLocalStorage } = {}) => { - return await queue.enqueue(async () => { - await waitInitialization() - if (!localStore) return - - const vaultKey = await config.get('key') - const useVault = vaultKey && vaultKey.hash === user.privates.vaultKeyHash - - if (!localOnly && useVault && !onlyFromLocalStorage) { - await apollo.mutate({ - mutation: UNSET_ENTRY, - variables: { key, ownerId: id, ownerType: type } - }) - } - // clear local storage - await localStore.unset(key) - }) - } - - const close = async () => { - return await queue.enqueue(async () => { - await config?.close() - await localStore?.close() - config = null - localStore = null - }) - } - - return { get: getValue, set: setValue, clear: clearValue, close } -} - -async function openConfig (userId) { - return await openLocalStorage({ userId, database: 'vault-config', namespace: ['settings'] }) -} - -/** - * Derive a key to be used for the vault encryption - * @param {string | number} userId - the id of the user (used for salting) - * @param {string} passphrase - the passphrase to derive the key from - * @returns {Promise<{key: CryptoKey, hash: string, extractable: boolean}>} an un-extractable CryptoKey and its hash - */ -async function deriveKey (userId, passphrase) { - const enc = new TextEncoder() - - const keyMaterial = await window.crypto.subtle.importKey( - 'raw', - enc.encode(passphrase), - { name: 'PBKDF2' }, - false, - ['deriveKey'] - ) - - const key = await window.crypto.subtle.deriveKey( - { - name: 'PBKDF2', - salt: enc.encode(`stacker${userId}`), - // 600,000 iterations is recommended by OWASP - // see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 - iterations: 600_000, - hash: 'SHA-256' - }, - keyMaterial, - { name: 'AES-GCM', length: 256 }, - true, - ['encrypt', 'decrypt'] - ) - - const rawKey = await window.crypto.subtle.exportKey('raw', key) - const hash = toHex(await window.crypto.subtle.digest('SHA-256', rawKey)) - const unextractableKey = await window.crypto.subtle.importKey( - 'raw', - rawKey, - { name: 'AES-GCM' }, - false, - ['encrypt', 'decrypt'] - ) - - return { - key: unextractableKey, - hash - } -} - -/** - * Encrypt data using AES-GCM - * @param {CryptoKey} sharedKey - the key to use for encryption - * @param {Object} data - the data to encrypt - * @returns {Promise} a string representing the encrypted data, can be passed to decryptData to get the original data back - */ -async function encryptData (sharedKey, data) { - // random IVs are _really_ important in GCM: reusing the IV once can lead to catastrophic failure - // see https://crypto.stackexchange.com/questions/26790/how-bad-it-is-using-the-same-iv-twice-with-aes-gcm - const iv = window.crypto.getRandomValues(new Uint8Array(12)) - const encoded = new TextEncoder().encode(JSON.stringify(data)) - const encrypted = await window.crypto.subtle.encrypt( - { - name: 'AES-GCM', - iv - }, - sharedKey, - encoded - ) - return JSON.stringify({ - iv: toHex(iv.buffer), - data: toHex(encrypted) - }) -} - -/** - * Decrypt data using AES-GCM - * @param {CryptoKey} sharedKey - the key to use for decryption - * @param {string} encryptedData - the encrypted data as returned by encryptData - * @returns {Promise} the original unencrypted data - */ -async function decryptData (sharedKey, encryptedData) { - const { iv, data } = JSON.parse(encryptedData) - const decrypted = await window.crypto.subtle.decrypt( - { - name: 'AES-GCM', - iv: fromHex(iv) - }, - sharedKey, - fromHex(data) - ) - const decoded = new TextDecoder().decode(decrypted) - return JSON.parse(decoded) -} diff --git a/components/vault/use-vault-configurator.js b/components/vault/use-vault-configurator.js new file mode 100644 index 00000000..ec5d849c --- /dev/null +++ b/components/vault/use-vault-configurator.js @@ -0,0 +1,145 @@ +import { UPDATE_VAULT_KEY } from '@/fragments/users' +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 { E_VAULT_KEY_EXISTS } from '@/lib/error' +import { CLEAR_VAULT, GET_VAULT_ENTRIES } from '@/fragments/vault' +import { toHex } from '@/lib/hex' +import { decryptData, encryptData } from './use-vault' + +const useImperativeQuery = (query) => { + const { refetch } = useQuery(query, { skip: true }) + + const imperativelyCallQuery = (variables) => { + return refetch(variables) + } + + return imperativelyCallQuery +} + +export function useVaultConfigurator () { + const { me } = useMe() + const toaster = useToast() + const { set, get, remove } = useIndexedDB({ dbName: getDbName(me?.id), storeName: 'vault' }) + const [updateVaultKey] = useMutation(UPDATE_VAULT_KEY) + const getVaultEntries = useImperativeQuery(GET_VAULT_ENTRIES) + const [key, setKey] = useState(null) + const [keyHash, setKeyHash] = useState(null) + + useEffect(() => { + if (!me) return + (async () => { + try { + let localVaultKey = await get('key') + const localKeyHash = me?.privates?.vaultKeyHash || keyHash + if (localVaultKey?.hash && localVaultKey?.hash !== localKeyHash) { + // If the hash stored in the server does not match the hash of the local key, + // we can tell that the key is outdated (reset by another device or other reasons) + // in this case we clear the local key and let the user re-enter the passphrase + console.log('vault key hash mismatch, clearing local key', localVaultKey?.hash, '!=', localKeyHash) + localVaultKey = null + await remove('key') + } + setKey(localVaultKey) + } catch (e) { + toaster.danger('error loading vault configuration ' + e.message) + } + })() + }, [me?.privates?.vaultKeyHash, keyHash, get, remove]) + + // clear vault: remove everything and reset the key + const [clearVault] = useMutation(CLEAR_VAULT, { + onCompleted: async () => { + try { + await remove('key') + setKey(null) + setKeyHash(null) + } catch (e) { + toaster.danger('error clearing vault ' + e.message) + } + } + }) + + // initialize the vault and set a vault key + const setVaultKey = useCallback(async (passphrase) => { + try { + const oldKeyValue = await get('key') + const vaultKey = await deriveKey(me.id, passphrase) + const { data } = await getVaultEntries() + + const entries = [] + for (const entry of data.getVaultEntries) { + entry.value = await decryptData(oldKeyValue.key, entry.value) + entries.push({ key: entry.key, value: await encryptData(vaultKey.key, entry.value) }) + } + + await updateVaultKey({ + variables: { entries, hash: vaultKey.hash }, + onError: (error) => { + const errorCode = error.graphQLErrors[0]?.extensions?.code + if (errorCode === E_VAULT_KEY_EXISTS) { + throw new Error('wrong passphrase') + } + toaster.danger(error.graphQLErrors[0].message) + } + }) + setKey(vaultKey) + setKeyHash(vaultKey.hash) + await set('key', vaultKey) + } catch (e) { + toaster.danger('error setting vault key ' + e.message) + } + }, [getVaultEntries, updateVaultKey, set, get, remove]) + + return [key, setVaultKey, clearVault] +} + +/** + * Derive a key to be used for the vault encryption + * @param {string | number} userId - the id of the user (used for salting) + * @param {string} passphrase - the passphrase to derive the key from + * @returns {Promise<{key: CryptoKey, hash: string, extractable: boolean}>} an un-extractable CryptoKey and its hash + */ +async function deriveKey (userId, passphrase) { + const enc = new TextEncoder() + + const keyMaterial = await window.crypto.subtle.importKey( + 'raw', + enc.encode(passphrase), + { name: 'PBKDF2' }, + false, + ['deriveKey'] + ) + + const key = await window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: enc.encode(`stacker${userId}`), + // 600,000 iterations is recommended by OWASP + // see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 + iterations: 600_000, + hash: 'SHA-256' + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ) + + const rawKey = await window.crypto.subtle.exportKey('raw', key) + const hash = toHex(await window.crypto.subtle.digest('SHA-256', rawKey)) + const unextractableKey = await window.crypto.subtle.importKey( + 'raw', + rawKey, + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'] + ) + + return { + key: unextractableKey, + hash + } +} diff --git a/components/vault/use-vault.js b/components/vault/use-vault.js new file mode 100644 index 00000000..4ae7e86a --- /dev/null +++ b/components/vault/use-vault.js @@ -0,0 +1,64 @@ +import { useCallback } from 'react' +import { useVaultConfigurator } from './use-vault-configurator' +import { fromHex, toHex } from '@/lib/hex' + +export default function useVault () { + const { key } = useVaultConfigurator() + + const encrypt = useCallback(async (value) => { + if (!key) throw new Error('no vault key set') + return await encryptData(key.key, value) + }, [key]) + + const decrypt = useCallback(async (value) => { + if (!key) throw new Error('no vault key set') + return await decryptData(key.key, value) + }, [key]) + + return { encrypt, decrypt, isActive: !!key } +} + +/** + * Encrypt data using AES-GCM + * @param {CryptoKey} sharedKey - the key to use for encryption + * @param {Object} data - the data to encrypt + * @returns {Promise} a string representing the encrypted data, can be passed to decryptData to get the original data back + */ +export async function encryptData (sharedKey, data) { + // random IVs are _really_ important in GCM: reusing the IV once can lead to catastrophic failure + // see https://crypto.stackexchange.com/questions/26790/how-bad-it-is-using-the-same-iv-twice-with-aes-gcm + const iv = window.crypto.getRandomValues(new Uint8Array(12)) + const encoded = new TextEncoder().encode(JSON.stringify(data)) + const encrypted = await window.crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv + }, + sharedKey, + encoded + ) + return JSON.stringify({ + iv: toHex(iv.buffer), + data: toHex(encrypted) + }) +} + +/** + * Decrypt data using AES-GCM + * @param {CryptoKey} sharedKey - the key to use for decryption + * @param {string} encryptedData - the encrypted data as returned by encryptData + * @returns {Promise} the original unencrypted data + */ +export async function decryptData (sharedKey, encryptedData) { + const { iv, data } = JSON.parse(encryptedData) + const decrypted = await window.crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: fromHex(iv) + }, + sharedKey, + fromHex(data) + ) + const decoded = new TextDecoder().decode(decrypted) + return JSON.parse(decoded) +} diff --git a/components/wallet-card.js b/components/wallet-card.js index aedd792c..7f2ae297 100644 --- a/components/wallet-card.js +++ b/components/wallet-card.js @@ -3,7 +3,7 @@ import styles from '@/styles/wallet.module.css' import Plug from '@/svgs/plug.svg' import Gear from '@/svgs/settings-5-fill.svg' import Link from 'next/link' -import { Status } from 'wallets' +import { Status } from '@/wallets/common' import DraggableIcon from '@/svgs/draggable.svg' export default function WalletCard ({ wallet, draggable, onDragStart, onDragEnter, onDragEnd, onTouchStart, sourceIndex, targetIndex, index }) { diff --git a/components/wallet-logger.js b/components/wallet-logger.js index d3df24e7..f9814c80 100644 --- a/components/wallet-logger.js +++ b/components/wallet-logger.js @@ -5,10 +5,10 @@ import { Button } from 'react-bootstrap' import { useToast } from './toast' import { useShowModal } from './modal' import { WALLET_LOGS } from '@/fragments/wallet' -import { getWalletByType } from 'wallets' +import { getWalletByType } from '@/wallets/common' import { gql, useLazyQuery, useMutation } from '@apollo/client' import { useMe } from './me' -import useIndexedDB from './use-indexeddb' +import useIndexedDB, { getDbName } from './use-indexeddb' import { SSR } from '@/lib/constants' export function WalletLogs ({ wallet, embedded }) { @@ -88,9 +88,11 @@ const INDICES = [ function useWalletLogDB () { const { me } = useMe() - const dbName = `app:storage${me ? `:${me.id}` : ''}` - const idbStoreName = 'wallet_logs' - const { add, getPage, clear, error, notSupported } = useIndexedDB(dbName, idbStoreName, 1, INDICES) + const { add, getPage, clear, error, notSupported } = useIndexedDB({ + dbName: getDbName(me?.id), + storeName: 'wallet_logs', + indices: INDICES + }) return { add, getPage, clear, error, notSupported } } diff --git a/fragments/users.js b/fragments/users.js index f42060d3..768be056 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -427,9 +427,3 @@ export const USER_STATS = gql` } } }` - -export const SET_VAULT_KEY_HASH = gql` - mutation setVaultKeyHash($hash: String!) { - setVaultKeyHash(hash: $hash) - } -` diff --git a/fragments/vault.js b/fragments/vault.js index 7bf7bab9..20f524dc 100644 --- a/fragments/vault.js +++ b/fragments/vault.js @@ -6,65 +6,38 @@ export const VAULT_FIELDS = gql` key value createdAt - updatedAt + updatedAt } ` -export const GET_ENTRY = gql` +export const GET_VAULT_ENTRY = gql` ${VAULT_FIELDS} query GetVaultEntry( - $ownerId: ID!, - $ownerType: String!, $key: String! ) { - getVaultEntry(ownerId: $ownerId, ownerType: $ownerType, key: $key) { + getVaultEntry(key: $key) { ...VaultFields } } ` -export const GET_ENTRIES = gql` +export const GET_VAULT_ENTRIES = gql` ${VAULT_FIELDS} - query GetVaultEntries( - $ownerId: ID!, - $ownerType: String! - ) { - getVaultEntries(ownerId: $ownerId, ownerType: $ownerType) { + query GetVaultEntries { + getVaultEntries { ...VaultFields } } ` -export const SET_ENTRY = gql` - mutation SetVaultEntry( - $ownerId: ID!, - $ownerType: String!, - $key: String!, - $value: String!, - $skipIfSet: Boolean - ) { - setVaultEntry(ownerId: $ownerId, ownerType: $ownerType, key: $key, value: $value, skipIfSet: $skipIfSet) - } -` - -export const UNSET_ENTRY = gql` - mutation UnsetVaultEntry( - $ownerId: ID!, - $ownerType: String!, - $key: String! - ) { - unsetVaultEntry(ownerId: $ownerId, ownerType: $ownerType, key: $key) - } -` - export const CLEAR_VAULT = gql` mutation ClearVault { clearVault } ` -export const SET_VAULT_KEY_HASH = gql` - mutation SetVaultKeyHash($hash: String!) { - setVaultKeyHash(hash: $hash) +export const UPDATE_VAULT_KEY = gql` + mutation updateVaultKey($entries: [VaultEntryInput!]!, $hash: String!) { + updateVaultKey(entries: $entries, hash: $hash) } ` diff --git a/fragments/wallet.js b/fragments/wallet.js index e5b4b226..67d38559 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -188,19 +188,7 @@ export const WALLET_BY_TYPE = gql` export const WALLETS = gql` query Wallets { - wallets{ - id - priority - type, - canSend, - canReceive - } - } -` - -export const BEST_WALLETS = gql` - query BestWallets { - wallets (includeSenders: true, includeReceivers: true, onlyEnabled: true, prioritySort: "asc") { + wallets { id priority type @@ -208,6 +196,10 @@ export const BEST_WALLETS = gql` canSend canReceive enabled + vaultEntries { + key + value + } } } ` @@ -222,7 +214,7 @@ export const WALLET_LOGS = gql` wallet level message + } } - } } ` diff --git a/lib/wallet.js b/lib/wallet.js deleted file mode 100644 index 65ec2e5e..00000000 --- a/lib/wallet.js +++ /dev/null @@ -1,62 +0,0 @@ -export function fieldToGqlArg (field) { - let arg = `${field.name}: String` - if (!field.optional) { - arg += '!' - } - return arg -} - -// same as fieldToGqlArg, but makes the field always optional -export function fieldToGqlArgOptional (field) { - return `${field.name}: String` -} - -export function generateResolverName (walletField) { - const capitalized = walletField[0].toUpperCase() + walletField.slice(1) - return `upsert${capitalized}` -} - -export function generateTypeDefName (walletType) { - const PascalCase = walletType.split('_').map(s => s[0].toUpperCase() + s.slice(1).toLowerCase()).join('') - return `Wallet${PascalCase}` -} - -export function isServerField (f) { - return f.serverOnly || !f.clientOnly -} - -export function isClientField (f) { - return f.clientOnly || !f.serverOnly -} - -/** - * Check if a wallet is configured based on its fields and config - * @param {*} param0 - * @param {*} param0.fields - the fields of the wallet - * @param {*} param0.config - the configuration of the wallet - * @param {*} param0.serverOnly - if true, only check server fields - * @param {*} param0.clientOnly - if true, only check client fields - * @returns - */ -export function isConfigured ({ fields, config, serverOnly = false, clientOnly = false }) { - if (!config || !fields) return false - - fields = fields.filter(f => { - if (clientOnly) return isClientField(f) - if (serverOnly) return isServerField(f) - return true - }) - - // a wallet is configured if all of its required fields are set - let val = fields.every(f => { - return f.optional ? true : !!config?.[f.name] - }) - - // however, a wallet is not configured if all fields are optional and none are set - // since that usually means that one of them is required - if (val && fields.length > 0) { - val = !(fields.every(f => f.optional) && fields.every(f => !config?.[f.name])) - } - - return val -} diff --git a/pages/_app.js b/pages/_app.js index 3320fcb2..95f2a446 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -22,7 +22,7 @@ import dynamic from 'next/dynamic' import { HasNewNotesProvider } from '@/components/use-has-new-notes' import { WebLnProvider } from '@/wallets/webln/client' import { AccountProvider } from '@/components/account' -import { WalletProvider } from '@/wallets/index' +import { WalletProvider } from '@/wallets/common' const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false }) diff --git a/pages/settings/wallets/[wallet].js b/pages/settings/wallets/[wallet].js index c40ba65c..80f41465 100644 --- a/pages/settings/wallets/[wallet].js +++ b/pages/settings/wallets/[wallet].js @@ -5,7 +5,7 @@ import { WalletSecurityBanner } from '@/components/banners' import { WalletLogs } from '@/components/wallet-logger' import { useToast } from '@/components/toast' import { useRouter } from 'next/router' -import { useWallet } from 'wallets' +import { useWallet } from '@/wallets/common' import Info from '@/components/info' import Text from '@/components/text' import { AutowithdrawSettings } from '@/components/autowithdraw-shared' diff --git a/pages/settings/wallets/index.js b/pages/settings/wallets/index.js index 880f0703..7712fd2f 100644 --- a/pages/settings/wallets/index.js +++ b/pages/settings/wallets/index.js @@ -2,7 +2,7 @@ import { getGetServerSideProps } from '@/api/ssrApollo' import Layout from '@/components/layout' import styles from '@/styles/wallet.module.css' import Link from 'next/link' -import { useWallets, walletPrioritySort } from 'wallets' +import { useWallets, walletPrioritySort } from '@/wallets/common' import { useState } from 'react' import dynamic from 'next/dynamic' import { useIsClient } from '@/components/use-client' diff --git a/prisma/migrations/20241011131443_vault/migration.sql b/prisma/migrations/20241011131443_vault/migration.sql deleted file mode 100644 index 03e82925..00000000 --- a/prisma/migrations/20241011131443_vault/migration.sql +++ /dev/null @@ -1,28 +0,0 @@ --- AlterTable -ALTER TABLE "users" ADD COLUMN "vaultKeyHash" TEXT NOT NULL DEFAULT ''; - --- CreateTable -CREATE TABLE "Vault" ( - "id" SERIAL NOT NULL, - "key" VARCHAR(64) NOT NULL, - "value" TEXT NOT NULL, - "userId" INTEGER NOT NULL, - "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "ownerId" INTEGER NOT NULL, - "ownerType" TEXT NOT NULL, - - CONSTRAINT "Vault_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "Vault.userId_index" ON "Vault"("userId"); - --- CreateIndex -CREATE INDEX "Vault.ownerId_ownerType_index" ON "Vault"("ownerId", "ownerType"); - --- CreateIndex -CREATE UNIQUE INDEX "Vault_userId_key_ownerId_ownerType_key" ON "Vault"("userId", "key", "ownerId", "ownerType"); - --- AddForeignKey -ALTER TABLE "Vault" ADD CONSTRAINT "Vault_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20241011131732_client_wallets/migration.sql b/prisma/migrations/20241011131732_client_wallets/migration.sql deleted file mode 100644 index 5ef68626..00000000 --- a/prisma/migrations/20241011131732_client_wallets/migration.sql +++ /dev/null @@ -1,15 +0,0 @@ --- AlterEnum --- This migration adds more than one value to an enum. --- With PostgreSQL versions 11 and earlier, this is not possible --- in a single migration. This can be worked around by creating --- multiple migrations, each migration adding only one value to --- the enum. - - -ALTER TYPE "WalletType" ADD VALUE 'BLINK'; -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; diff --git a/prisma/migrations/20241021224248_vault/migration.sql b/prisma/migrations/20241021224248_vault/migration.sql new file mode 100644 index 00000000..dd318e1f --- /dev/null +++ b/prisma/migrations/20241021224248_vault/migration.sql @@ -0,0 +1,46 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "WalletType" ADD VALUE 'BLINK'; +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 ''; + +-- CreateTable +CREATE TABLE "VaultEntry" ( + "id" SERIAL NOT NULL, + "key" VARCHAR(64) NOT NULL, + "value" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "walletId" INTEGER, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + 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"); + +-- 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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2884df91..e9496a97 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -138,7 +138,7 @@ model User { oneDayReferrals OneDayReferral[] @relation("OneDayReferral_referrer") oneDayReferrees OneDayReferral[] @relation("OneDayReferral_referrees") vaultKeyHash String @default("") - vaultEntries Vault[] @relation("VaultEntries") + vaultEntries VaultEntry[] @relation("VaultEntries") @@index([photoId]) @@index([createdAt], map: "users.created_at_index") @@ -187,14 +187,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) + 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) @@ -212,12 +212,30 @@ model Wallet { walletLNbits WalletLNbits? walletNWC WalletNWC? walletPhoenixd WalletPhoenixd? - withdrawals Withdrawl[] - InvoiceForward InvoiceForward[] + + vaultEntries VaultEntry[] @relation("VaultEntries") + withdrawals Withdrawl[] + InvoiceForward InvoiceForward[] @@index([userId]) } +model VaultEntry { + id Int @id @default(autoincrement()) + key String @db.VarChar(64) + value String @db.Text + userId Int + walletId Int? + user User @relation(fields: [userId], references: [id], onDelete: Cascade, name: "VaultEntries") + wallet Wallet? @relation(fields: [walletId], references: [id], onDelete: Cascade, name: "VaultEntries") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + + @@unique([userId, key, walletId]) + @@index([userId]) + @@index([walletId]) +} + model WalletLog { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map("created_at") @@ -1120,22 +1138,6 @@ model Reminder { @@index([userId, remindAt], map: "Reminder.userId_reminderAt_index") } -model Vault { - id Int @id @default(autoincrement()) - key String @db.VarChar(64) - value String @db.Text - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade, name: "VaultEntries") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - ownerId Int - ownerType String - - @@unique([userId, key, ownerId, ownerType]) - @@index([userId], map: "Vault.userId_index") - @@index([ownerId, ownerType], map: "Vault.ownerId_ownerType_index") -} - enum EarnType { POST COMMENT diff --git a/wallets/common.js b/wallets/common.js new file mode 100644 index 00000000..07124483 --- /dev/null +++ b/wallets/common.js @@ -0,0 +1,47 @@ +import walletDefs from 'wallets/client' + +export const Status = { + Initialized: 'Initialized', + Enabled: 'Enabled', + Locked: 'Locked', + Error: 'Error' +} + +export function getWalletByName (name) { + return walletDefs.find(def => def.name === name) +} + +export function getWalletByType (type) { + return walletDefs.find(def => def.walletType === type) +} + +export function getStorageKey (name, me) { + let storageKey = `wallet:${name}` + + // WebLN has no credentials we need to scope to users + // so we can use the same storage key for all users + if (me && name !== 'webln') { + storageKey = `${storageKey}:${me.id}` + } + + return storageKey +} + +export function walletPrioritySort (w1, w2) { + const delta = w1.priority - w2.priority + // delta is NaN if either priority is undefined + if (!Number.isNaN(delta) && delta !== 0) return delta + + // if one wallet has a priority but the other one doesn't, the one with the priority comes first + if (w1.priority !== undefined && w2.priority === undefined) return -1 + if (w1.priority === undefined && w2.priority !== undefined) return 1 + + // both wallets have no priority set, falling back to other methods + + // if both wallets have an id, use that as tie breaker + // since that's the order in which autowithdrawals are attempted + if (w1.config?.id && w2.config?.id) return Number(w1.config.id) - Number(w2.config.id) + + // else we will use the card title as tie breaker + return w1.card.title < w2.card.title ? -1 : 1 +} diff --git a/wallets/config.js b/wallets/config.js new file mode 100644 index 00000000..05cc3cdf --- /dev/null +++ b/wallets/config.js @@ -0,0 +1,164 @@ +import { useMe } from '@/components/me' +import useVault from '@/components/use-vault' +import { useCallback } from 'react' +import { getStorageKey } from './common' +import { useMutation } from '@apollo/client' +import { generateMutation } from './graphql' +import { REMOVE_WALLET } from '@/fragments/wallet' +import { walletValidate } from '@/lib/validate' +import { useWalletLogger } from '@/components/wallet-logger' + +export function useWalletConfigurator (wallet) { + const { me } = useMe() + const { encrypt, isActive } = useVault() + const { logger } = useWalletLogger(wallet.def) + const [upsertWallet] = useMutation(generateMutation(wallet.def)) + const [removeWallet] = useMutation(REMOVE_WALLET) + + const _saveToServer = useCallback(async (serverConfig, clientConfig) => { + const vaultEntries = [] + if (clientConfig) { + for (const [key, value] of Object.entries(clientConfig)) { + vaultEntries.push({ key, value: encrypt(value) }) + } + } + await upsertWallet({ variables: { ...serverConfig, vaultEntries } }) + }, [encrypt, isActive]) + + const _saveToLocal = useCallback(async (newConfig) => { + window.localStorage.setItem(getStorageKey(wallet.name, me), JSON.stringify(newConfig)) + }, [me, wallet.name]) + + const save = useCallback(async (newConfig, validate = true) => { + let clientConfig = extractClientConfig(wallet.def.fields, newConfig) + let serverConfig = extractServerConfig(wallet.def.fields, newConfig) + + if (validate) { + if (clientConfig) { + let transformedConfig = await walletValidate(wallet, clientConfig) + if (transformedConfig) { + clientConfig = Object.assign(clientConfig, transformedConfig) + } + transformedConfig = await wallet.def.testSendPayment(clientConfig, { me, logger }) + if (transformedConfig) { + clientConfig = Object.assign(clientConfig, transformedConfig) + } + } + + if (serverConfig) { + const transformedConfig = await walletValidate(wallet, serverConfig) + if (transformedConfig) { + serverConfig = Object.assign(serverConfig, transformedConfig) + } + } + } + + // if vault is active, encrypt and send to server regardless of wallet type + if (isActive) { + await _saveToServer(serverConfig, clientConfig) + } else { + if (clientConfig) { + await _saveToLocal(clientConfig) + } + if (serverConfig) { + await _saveToServer(serverConfig) + } + } + }, [wallet.def, encrypt, isActive]) + + const _detachFromServer = useCallback(async () => { + await removeWallet({ variables: { id: wallet.config.id } }) + }, [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.name, me)) + }, [me, wallet.name]) + + const detach = useCallback(async () => { + if (isActive) { + await _detachFromServer() + } else { + if (wallet.config.id) { + await _detachFromServer() + } + + await _detachFromLocal() + } + }, [isActive, _detachFromServer, _detachFromLocal]) + + return [save, detach] +} + +function extractConfig (fields, config, client, includeMeta = true) { + return Object.entries(config).reduce((acc, [key, value]) => { + const field = fields.find(({ name }) => name === key) + + // filter server config which isn't specified as wallet fields + // (we allow autowithdraw members to pass validation) + if (client && key === 'id') return acc + + // field might not exist because config.enabled doesn't map to a wallet field + if ((!field && includeMeta) || (field && (client ? isClientField(field) : isServerField(field)))) { + return { + ...acc, + [key]: value + } + } else { + return acc + } + }, {}) +} + +function extractClientConfig (fields, config) { + return extractConfig(fields, config, true, false) +} + +function extractServerConfig (fields, config) { + return extractConfig(fields, config, false, true) +} + +export function isServerField (f) { + return f.serverOnly || !f.clientOnly +} + +export function isClientField (f) { + return f.clientOnly || !f.serverOnly +} + +function checkFields ({ fields, config }) { + // a wallet is configured if all of its required fields are set + let val = fields.every(f => { + return f.optional ? true : !!config?.[f.name] + }) + + // however, a wallet is not configured if all fields are optional and none are set + // since that usually means that one of them is required + if (val && fields.length > 0) { + val = !(fields.every(f => f.optional) && fields.every(f => !config?.[f.name])) + } + + return val +} + +export function isConfigured (wallet) { + return isSendConfigured(wallet) || isReceiveConfigured(wallet) +} + +function isSendConfigured (wallet) { + const fields = wallet.def.fields.filter(isClientField) + return checkFields({ fields, config: wallet.config }) +} + +function isReceiveConfigured (wallet) { + const fields = wallet.def.fields.filter(isServerField) + return checkFields({ fields, config: wallet.config }) +} + +export function canSend (wallet) { + return !!wallet.def.sendPayment && isSendConfigured(wallet) +} + +export function canReceive (wallet) { + return !wallet.def.clientOnly && isReceiveConfigured(wallet) +} diff --git a/wallets/graphql.js b/wallets/graphql.js new file mode 100644 index 00000000..4fe0d31f --- /dev/null +++ b/wallets/graphql.js @@ -0,0 +1,59 @@ +import gql from 'graphql-tag' +import { isServerField } from './config' + +export function fieldToGqlArg (field) { + let arg = `${field.name}: String` + if (!field.optional) { + arg += '!' + } + return arg +} + +// same as fieldToGqlArg, but makes the field always optional +export function fieldToGqlArgOptional (field) { + return `${field.name}: String` +} + +export function generateResolverName (walletField) { + const capitalized = walletField[0].toUpperCase() + walletField.slice(1) + return `upsert${capitalized}` +} + +export function generateTypeDefName (walletType) { + const PascalCase = walletType.split('_').map(s => s[0].toUpperCase() + s.slice(1).toLowerCase()).join('') + return `Wallet${PascalCase}` +} + +export function generateMutation (wallet) { + const resolverName = generateResolverName(wallet.walletField) + + let headerArgs = '$id: ID, ' + headerArgs += wallet.fields + .filter(isServerField) + .map(f => { + const arg = `$${f.name}: String` + // required fields are checked server-side + // if (!f.optional) { + // arg += '!' + // } + return arg + }).join(', ') + headerArgs += ', $settings: AutowithdrawSettings!, $priorityOnly: Boolean, $canSend: Boolean!, $canReceive: Boolean!' + + let inputArgs = 'id: $id, ' + inputArgs += wallet.fields + .filter(isServerField) + .map(f => `${f.name}: $${f.name}`).join(', ') + inputArgs += ', settings: $settings, priorityOnly: $priorityOnly, canSend: $canSend, canReceive: $canReceive,' + + return gql`mutation ${resolverName}(${headerArgs}) { + ${resolverName}(${inputArgs}) { + id, + type, + enabled, + priority, + canReceive, + canSend + } + }` +} diff --git a/wallets/index.js b/wallets/index.js index b0079594..4f466e0b 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -1,532 +1,123 @@ -import { createContext, useContext, useCallback, useState, useEffect, useRef } from 'react' import { useMe } from '@/components/me' -import { openVault } from '@/components/use-vault' +import { WALLETS } from '@/fragments/wallet' +import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants' +import { useQuery } from '@apollo/client' +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import { getStorageKey, getWalletByType } from './common' +import useVault from '@/components/use-vault' import { useWalletLogger } from '@/components/wallet-logger' import { bolt11Tags } from '@/lib/bolt11' - import walletDefs from 'wallets/client' -import { gql, useApolloClient, useMutation, useQuery } from '@apollo/client' -import { REMOVE_WALLET, WALLET_BY_TYPE, BEST_WALLETS } from '@/fragments/wallet' -import { autowithdrawInitial } from '@/components/autowithdraw-shared' -import { useShowModal } from '@/components/modal' -import { useToast } from '../components/toast' -import { generateResolverName, isConfigured, isClientField, isServerField } from '@/lib/wallet' -import { walletValidate } from '@/lib/validate' -import { SSR, FAST_POLL_INTERVAL as POLL_INTERVAL } from '@/lib/constants' +import { canSend } from './config' -export const Status = { - Initialized: 'Initialized', - Enabled: 'Enabled', - Locked: 'Locked', - Error: 'Error' -} - -const WalletContext = createContext({ - wallets: [], - sendWallets: [] +const WalletsContext = createContext({ + wallets: [] }) -export function useWallet (name) { - const context = useContext(WalletContext) - const bestSendWalletList = context.sendWallets - if (!name) { - // find best wallet in list - const highestWalletDef = bestSendWalletList?.map(w => getWalletByType(w.type)) - .filter(w => !w.isAvailable || w.isAvailable()) - name = highestWalletDef?.[0]?.name - } - const wallet = context.wallets.find(w => w.def.name === name) - return wallet +function useLocalWallets () { + const { me } = useMe() + const [wallets, setWallets] = useState([]) + + const loadWallets = useCallback(() => { + // form wallets into a list of { config, def } + const wallets = walletDefs.map(w => { + try { + const config = window.localStorage.getItem(getStorageKey(w.name, me)) + return { def: w, config: JSON.parse(config) } + } catch (e) { + return null + } + }).filter(Boolean) + setWallets(wallets) + }, [me, setWallets]) + + // watch for changes to local storage + useEffect(() => { + loadWallets() + // reload wallets if local storage to wallet changes + const handler = (event) => { + if (event.key.startsWith('wallet:')) { + loadWallets() + } + } + window.addEventListener('storage', handler) + return () => window.removeEventListener('storage', handler) + }, [loadWallets]) + + return wallets } -function useWalletInner (name) { +export function WalletsProvider ({ children }) { const { me } = useMe() - const showModal = useShowModal() - const toaster = useToast() - const [disableFreebies] = useMutation(gql`mutation { disableFreebies }`) + const { decrypt } = useVault() + const localWallets = useLocalWallets() - const walletDef = getWalletByName(name) + // 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: NORMAL_POLL_INTERVAL, + nextFetchPolicy: 'cache-and-network', + skip: !me?.id || SSR + }) - const { logger, deleteLogs } = useWalletLogger(walletDef) - const [config, saveConfig, clearConfig, refreshConfig] = useConfig(walletDef) - const available = (!walletDef?.isAvailable || walletDef?.isAvailable()) + const wallets = useMemo(() => { + // form wallets into a list of { config, def } + const wallets = data?.wallets?.map(w => { + const def = getWalletByType(w.type) + const { vaultEntries, ...config } = w + for (const { key, value } of vaultEntries) { + config[key] = decrypt(value) + } - const status = config?.enabled && available && (config.canSend || config.canReceive) ? Status.Enabled : Status.Initialized - const enabled = status === Status.Enabled - const priority = config?.priority - const hasConfig = walletDef?.fields?.length > 0 - const _isConfigured = useCallback(() => { - return isConfigured({ ...walletDef, config }) - }, [walletDef, config]) + return { config, def } + }) - const enablePayments = useCallback((updatedConfig) => { - saveConfig({ ...(updatedConfig || config), enabled: true }, { skipTests: true }) - logger.ok('payments enabled') - disableFreebies().catch(console.error) - }, [config]) + // merge wallets on name + const merged = {} + for (const wallet of [...localWallets, ...wallets]) { + merged[wallet.def.name] = { ...merged[wallet.def.name], ...wallet } + } + return Object.values(merged) + }, [data?.wallets, localWallets]) - const disablePayments = useCallback((updatedConfig) => { - saveConfig({ ...(updatedConfig || config), enabled: false }, { skipTests: true }) - logger.info('payments disabled') - }, [config]) + return ( + + {children} + + ) +} + +export function useWallets () { + return useContext(WalletsContext) +} + +export function useWallet (name) { + const wallets = useWallets() + + const wallet = useMemo(() => { + if (name) { + return wallets.find(w => w.def.name === name) + } + + return wallets + .filter(w => !w.def.isAvailable || w.def.isAvailable()) + .filter(w => w.config.enabled && canSend(w))[0] + }, [wallets, name]) + + const { logger } = useWalletLogger(wallet.def) const sendPayment = useCallback(async (bolt11) => { const hash = bolt11Tags(bolt11).payment_hash logger.info('sending payment:', `payment_hash=${hash}`) try { - const preimage = await walletDef.sendPayment(bolt11, config, { me, logger, status, showModal }) + const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger }) logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`) } catch (err) { const message = err.message || err.toString?.() logger.error('payment failed:', `payment_hash=${hash}`, message) throw err } - }, [me, walletDef, config]) + }, [wallet, logger]) - const setPriority = useCallback(async (priority) => { - if (_isConfigured() && priority !== config.priority) { - try { - await saveConfig({ ...config, priority }, { logger, skipTests: true }) - } catch (err) { - toaster.danger(`failed to change priority of ${walletDef.name} wallet: ${err.message}`) - } - } - }, [walletDef, config]) - - const save = useCallback(async (newConfig) => { - await saveConfig(newConfig, { logger }) - const available = (!walletDef.isAvailable || walletDef.isAvailable()) - logger.ok(_isConfigured() ? 'payment details updated' : 'wallet attached for payments') - if (newConfig.enabled && available) logger.ok('payments enabled') - else logger.ok('payments disabled') - }, [saveConfig, me]) - - // delete is a reserved keyword - const delete_ = useCallback(async (options) => { - try { - logger.ok('wallet detached for payments') - await clearConfig({ logger, ...options }) - } catch (err) { - const message = err.message || err.toString?.() - logger.error(message) - throw err - } - }, [clearConfig]) - - const deleteLogs_ = useCallback(async (options) => { - // first argument is to override the wallet - return await deleteLogs(options) - }, [deleteLogs]) - - if (!walletDef) return null - - const wallet = { ...walletDef } - - wallet.isConfigured = _isConfigured() - wallet.enablePayments = enablePayments - wallet.disablePayments = disablePayments - wallet.canSend = config.canSend && available - wallet.canReceive = config.canReceive - wallet.config = config - wallet.save = save - wallet.delete = delete_ - wallet.deleteLogs = deleteLogs_ - wallet.setPriority = setPriority - wallet.hasConfig = hasConfig - wallet.status = status - wallet.enabled = enabled - wallet.available = available - wallet.priority = priority - wallet.logger = logger - wallet.sendPayment = sendPayment - wallet.def = walletDef - wallet.refresh = () => { - return refreshConfig() - } - return wallet -} - -function extractConfig (fields, config, client, includeMeta = true) { - return Object.entries(config).reduce((acc, [key, value]) => { - const field = fields.find(({ name }) => name === key) - - // filter server config which isn't specified as wallet fields - // (we allow autowithdraw members to pass validation) - if (client && key === 'id') return acc - - // field might not exist because config.enabled doesn't map to a wallet field - if ((!field && includeMeta) || (field && (client ? isClientField(field) : isServerField(field)))) { - return { - ...acc, - [key]: value - } - } else { - return acc - } - }, {}) -} - -function extractClientConfig (fields, config) { - return extractConfig(fields, config, true, false) -} - -function extractServerConfig (fields, config) { - return extractConfig(fields, config, false, true) -} - -function useConfig (walletDef) { - const client = useApolloClient() - const { me } = useMe() - const toaster = useToast() - const autowithdrawSettings = autowithdrawInitial({ me }) - const clientVault = useRef(null) - - const [config, innerSetConfig] = useState({}) - const [currentWallet, innerSetCurrentWallet] = useState(null) - - const canSend = !!walletDef?.sendPayment - const canReceive = !walletDef?.clientOnly - - const queryServerWallet = useCallback(async () => { - const wallet = await client.query({ - query: WALLET_BY_TYPE, - variables: { type: walletDef.walletType }, - fetchPolicy: 'network-only' - }) - return wallet?.data?.walletByType - }, [walletDef, client]) - - const refreshConfig = useCallback(async () => { - if (!me?.id) return - if (walletDef) { - let newConfig = {} - newConfig = { - ...autowithdrawSettings - } - - // fetch server config - const serverConfig = await queryServerWallet() - - if (serverConfig) { - newConfig = { - ...newConfig, - id: serverConfig.id, - priority: serverConfig.priority, - enabled: serverConfig.enabled - } - if (serverConfig.wallet) { - newConfig = { - ...newConfig, - ...serverConfig.wallet - } - } - } - - // fetch client config - let clientConfig = {} - if (serverConfig) { - if (clientVault.current) clientVault.current.close() - const newClientVault = openVault(client, me, serverConfig) - clientVault.current = newClientVault - clientConfig = await newClientVault.get(walletDef.name, {}) - if (clientConfig) { - for (const [key, value] of Object.entries(clientConfig)) { - if (newConfig[key] === undefined) { - newConfig[key] = value - } else { - console.warn('Client config key', key, 'already exists in server config') - } - } - } - } - - if (newConfig.canSend == null) { - newConfig.canSend = canSend && isConfigured({ fields: walletDef.fields, config: newConfig, clientOnly: true }) - } - - if (newConfig.canReceive == null) { - newConfig.canReceive = canReceive && isConfigured({ fields: walletDef.fields, config: newConfig, serverOnly: true }) - } - - // console.log('Client config', clientConfig) - // console.log('Server config', serverConfig) - // console.log('Merged config', newConfig) - - // set merged config - innerSetConfig(newConfig) - - // set wallet ref - innerSetCurrentWallet(serverConfig) - } - }, [walletDef, me]) - - useEffect(() => { - refreshConfig() - }, [walletDef, me]) - - const saveConfig = useCallback(async (newConfig, { logger, skipTests }) => { - const serverConfig = await queryServerWallet() - const priorityOnly = skipTests - try { - // gather configs - - let newClientConfig = extractClientConfig(walletDef.fields, newConfig) - try { - const transformedConfig = await walletValidate(walletDef, newClientConfig) - if (transformedConfig) { - newClientConfig = Object.assign(newClientConfig, transformedConfig) - } - } catch (e) { - newClientConfig = {} - } - - let newServerConfig = extractServerConfig(walletDef.fields, newConfig) - try { - const transformedConfig = await walletValidate(walletDef, newServerConfig) - if (transformedConfig) { - newServerConfig = Object.assign(newServerConfig, transformedConfig) - } - } catch (e) { - newServerConfig = {} - } - - // check if it misses send or receive configs - const isReadyToSend = canSend && isConfigured({ fields: walletDef.fields, config: newConfig, clientOnly: true }) - const isReadyToReceive = canReceive && isConfigured({ fields: walletDef.fields, config: newConfig, serverOnly: true }) - const { autoWithdrawThreshold, autoWithdrawMaxFeePercent, autoWithdrawMaxFeeTotal, priority, enabled } = newConfig - - // console.log('New client config', newClientConfig) - // console.log('New server config', newServerConfig) - // console.log('Sender', isReadyToSend, 'Receiver', isReadyToReceive, 'enabled', enabled, autoWithdrawThreshold, autoWithdrawMaxFeePercent, priority) - - // client test - if (!skipTests && isReadyToSend && enabled) { - try { - // XXX: testSendPayment can return a new config (e.g. lnc) - const newerConfig = await walletDef.testSendPayment?.(newClientConfig, { me, logger }) - if (newerConfig) { - newClientConfig = Object.assign(newClientConfig, newerConfig) - } - } catch (err) { - logger.error(err.message) - throw err - } - } - - // set server config (will create wallet if it doesn't exist) (it is also testing receive config) - if (!isReadyToSend && !isReadyToReceive) throw new Error('wallet should be configured to send or receive payments') - - const mutation = generateMutation(walletDef) - const variables = { - ...newServerConfig, - id: serverConfig?.id, - settings: { - autoWithdrawThreshold: Number(autoWithdrawThreshold == null ? autowithdrawSettings.autoWithdrawThreshold : autoWithdrawThreshold), - autoWithdrawMaxFeePercent: Number(autoWithdrawMaxFeePercent == null ? autowithdrawSettings.autoWithdrawMaxFeePercent : autoWithdrawMaxFeePercent), - autoWithdrawMaxFeeTotal: Number(autoWithdrawMaxFeeTotal == null ? autowithdrawSettings.autoWithdrawMaxFeeTotal : autoWithdrawMaxFeeTotal), - priority, - enabled - }, - canSend: isReadyToSend, - canReceive: isReadyToReceive, - priorityOnly - } - const { data: mutationResult, errors: mutationErrors } = await client.mutate({ - mutation, - variables - }) - - if (mutationErrors) { - throw new Error(mutationErrors[0].message) - } - - // grab and update wallet ref - const newWallet = mutationResult[generateResolverName(walletDef.walletField)] - innerSetCurrentWallet(newWallet) - - // set client config - const writeVault = openVault(client, me, newWallet, {}) - try { - await writeVault.set(walletDef.name, newClientConfig) - } finally { - await writeVault.close() - } - } finally { - client.refetchQueries({ include: ['WalletLogs'] }) - await refreshConfig() - } - }, [config, currentWallet, canSend, canReceive]) - - const clearConfig = useCallback(async ({ logger, clientOnly, ...options }) => { - // only remove wallet if there is a wallet to remove - if (!currentWallet?.id) return - try { - const clearVault = openVault(client, me, currentWallet, {}) - try { - await clearVault.clear(walletDef?.name, { onlyFromLocalStorage: clientOnly }) - } catch (e) { - toaster.danger(`failed to clear client config for ${walletDef.name}: ${e.message}`) - } finally { - await clearVault.close() - } - - if (!clientOnly) { - try { - await client.mutate({ - mutation: REMOVE_WALLET, - variables: { id: currentWallet.id } - }) - } catch (e) { - toaster.danger(`failed to remove wallet ${currentWallet.id}: ${e.message}`) - } - } - } finally { - client.refetchQueries({ include: ['WalletLogs'] }) - await refreshConfig() - } - }, [config, currentWallet]) - - return [config, saveConfig, clearConfig, refreshConfig] -} - -function generateMutation (wallet) { - const resolverName = generateResolverName(wallet.walletField) - - let headerArgs = '$id: ID, ' - headerArgs += wallet.fields - .filter(isServerField) - .map(f => { - const arg = `$${f.name}: String` - // required fields are checked server-side - // if (!f.optional) { - // arg += '!' - // } - return arg - }).join(', ') - headerArgs += ', $settings: AutowithdrawSettings!, $priorityOnly: Boolean, $canSend: Boolean!, $canReceive: Boolean!' - - let inputArgs = 'id: $id, ' - inputArgs += wallet.fields - .filter(isServerField) - .map(f => `${f.name}: $${f.name}`).join(', ') - inputArgs += ', settings: $settings, priorityOnly: $priorityOnly, canSend: $canSend, canReceive: $canReceive,' - - return gql`mutation ${resolverName}(${headerArgs}) { - ${resolverName}(${inputArgs}) { - id, - type, - enabled, - priority, - canReceive, - canSend - } - }` -} - -export function getWalletByName (name) { - return walletDefs.find(def => def.name === name) -} - -export function getWalletByType (type) { - return walletDefs.find(def => def.walletType === type) -} - -export function walletPrioritySort (w1, w2) { - const delta = w1.priority - w2.priority - // delta is NaN if either priority is undefined - if (!Number.isNaN(delta) && delta !== 0) return delta - - // if one wallet has a priority but the other one doesn't, the one with the priority comes first - if (w1.priority !== undefined && w2.priority === undefined) return -1 - if (w1.priority === undefined && w2.priority !== undefined) return 1 - - // both wallets have no priority set, falling back to other methods - - // if both wallets have an id, use that as tie breaker - // since that's the order in which autowithdrawals are attempted - if (w1.config?.id && w2.config?.id) return Number(w1.config.id) - Number(w2.config.id) - - // else we will use the card title as tie breaker - return w1.card.title < w2.card.title ? -1 : 1 -} - -export function useWallets () { - const { wallets } = useContext(WalletContext) - const resetClient = useCallback(async (wallet) => { - for (const w of wallets) { - if (w.canSend) { - await w.delete({ clientOnly: true, onlyFromLocalStorage: true }) - } - await w.deleteLogs({ clientOnly: true }) - } - }, [wallets]) - return { wallets, resetClient } -} - -export function WalletProvider ({ children }) { - const { me } = useMe() - const migrationRan = useRef(false) - const migratableKeys = !migrationRan.current && !SSR ? Object.keys(window.localStorage).filter(k => k.startsWith('wallet:')) : undefined - - const walletList = walletDefs.map(def => useWalletInner(def.name)).filter(w => w) - const { data: bestWalletList } = useQuery(BEST_WALLETS, { - pollInterval: POLL_INTERVAL, - nextFetchPolicy: 'cache-and-network', - skip: !me?.id - }) - - const processSendWallets = (bestWalletData) => { - const clientSideSorting = false // sorting is now done on the server - let wallets = (bestWalletData?.wallets ?? []).filter(w => w.canSend) - if (clientSideSorting) wallets = wallets.sort(walletPrioritySort) - return wallets - } - - const wallets = walletList.sort(walletPrioritySort) - const [bestSendWallets, innerSetBestSendWallets] = useState(() => processSendWallets(bestWalletList)) - - useEffect(() => { - innerSetBestSendWallets(processSendWallets(bestWalletList)) - for (const wallet of wallets) { - wallet.refresh() - } - }, [bestWalletList]) - - // migration - useEffect(() => { - if (SSR || !me?.id || !wallets.length) return - if (migrationRan.current) return - migrationRan.current = true - if (!migratableKeys?.length) { - console.log('wallet migrator: nothing to migrate', migratableKeys) - return - } - const userId = me.id - // List all local storage keys related to wallet settings - const userKeys = migratableKeys.filter(k => k.endsWith(`:${userId}`)) - ;(async () => { - for (const key of userKeys) { - try { - const walletType = key.substring('wallet:'.length, key.length - userId.length - 1) - const walletConfig = JSON.parse(window.localStorage.getItem(key)) - const wallet = wallets.find(w => w.def.name === walletType) - if (wallet) { - console.log('Migrating', walletType, walletConfig) - await wallet.save(walletConfig) - window.localStorage.removeItem(key) - } else { - console.warn('No wallet found for', walletType, wallets) - } - } catch (e) { - console.error('Failed to migrate wallet', key, e) - } - } - })() - }, []) - - return ( - - {children} - - ) + return { ...wallet, sendPayment } } From 48640cbed6449623b2c02ea8ee9d50c11b37aed9 Mon Sep 17 00:00:00 2001 From: k00b Date: Wed, 23 Oct 2024 12:42:34 -0500 Subject: [PATCH 38/58] pages load *kazoo* --- api/resolvers/vault.js | 7 +-- api/resolvers/wallet.js | 3 +- api/typeDefs/wallet.js | 4 +- components/autowithdraw-shared.js | 3 +- components/device-sync.js | 37 +----------- components/item-act.js | 2 +- components/nav/common.js | 5 +- components/payment.js | 26 +-------- components/qr.js | 2 +- components/use-indexeddb.js | 4 +- components/vault/use-vault-configurator.js | 9 ++- components/wallet-buttonbar.js | 5 +- components/wallet-card.js | 18 ++---- components/wallet-logger.js | 10 +++- pages/_app.js | 6 +- pages/settings/wallets/[wallet].js | 31 +++++----- pages/settings/wallets/index.js | 68 ++++++++-------------- wallets/common.js | 64 +++++++++++++++++--- wallets/config.js | 53 ++--------------- wallets/errors.js | 22 +++++++ wallets/graphql.js | 2 +- wallets/index.js | 18 +++--- wallets/lnc/client.js | 2 +- 23 files changed, 177 insertions(+), 224 deletions(-) create mode 100644 wallets/errors.js diff --git a/api/resolvers/vault.js b/api/resolvers/vault.js index ecb8ec59..99c0486e 100644 --- a/api/resolvers/vault.js +++ b/api/resolvers/vault.js @@ -54,10 +54,9 @@ export default { } for (const entry of entries) { - txs.push(models.vaultEntry.upsert({ - where: { userId: me.id, key: entry.key }, - update: { key: entry.key, value: entry.value }, - create: { key: entry.key, value: entry.value, userId: me.id, walletId: entry.walletId } + txs.push(models.vaultEntry.update({ + where: { id: entry.id }, + data: { key: entry.key, value: entry.value } })) } await models.prisma.$transaction(txs) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index aecd05fd..a0951a81 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -19,7 +19,8 @@ import assertApiKeyNotPermitted from './apiKey' import { bolt11Tags } from '@/lib/bolt11' import { finalizeHodlInvoice } from 'worker/wallet' import walletDefs from 'wallets/server' -import { generateResolverName, generateTypeDefName, isConfigured } from '@/lib/wallet' +import { generateResolverName, generateTypeDefName } from '@/wallets/graphql' +import { isConfigured } from '@/wallets/common' import { lnAddrOptions } from '@/lib/lnurl' import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error' import { getNodeSockets, getOurPubkey } from '../lnd' diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index 406b8891..ad697217 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -1,6 +1,6 @@ import { gql } from 'graphql-tag' -import { fieldToGqlArg, fieldToGqlArgOptional, generateResolverName, generateTypeDefName, isServerField } from '@/lib/wallet' - +import { fieldToGqlArg, fieldToGqlArgOptional, generateResolverName, generateTypeDefName } from '@/wallets/graphql' +import { isServerField } from '@/wallets/common' import walletDefs from 'wallets/server' function injectTypeDefs (typeDefs) { diff --git a/components/autowithdraw-shared.js b/components/autowithdraw-shared.js index 27917ed9..47f01bb8 100644 --- a/components/autowithdraw-shared.js +++ b/components/autowithdraw-shared.js @@ -5,6 +5,7 @@ import { useEffect, useState } from 'react' import { isNumber } from '@/lib/validate' import { useIsClient } from './use-client' import Link from 'next/link' +import { isConfigured } from '@/wallets/common' function autoWithdrawThreshold ({ me }) { return isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000 @@ -33,7 +34,7 @@ export function AutowithdrawSettings ({ wallet }) { return ( <> { if (enabled && connected) { showModal((onClose) => ( @@ -55,7 +52,7 @@ export default function DeviceSync () { )) } - }, [migrate, enabled, connected, value]) + }, [enabled, connected, value]) const reset = useCallback(async () => { const schema = yup.object().shape({ @@ -106,7 +103,6 @@ export default function DeviceSync () { if (values.passphrase) { try { await setVaultKey(values.passphrase) - await migrate() apollo.cache.evict({ fieldName: 'BestWallets' }) apollo.cache.gc() await apollo.refetchQueries({ include: ['BestWallets'] }) @@ -115,7 +111,7 @@ export default function DeviceSync () { throw e } } - }, [setVaultKey, migrate]) + }, [setVaultKey]) return ( <> @@ -139,33 +135,6 @@ export default function DeviceSync () {

- - - {enabled && !connected && (
diff --git a/components/item-act.js b/components/item-act.js index 36eaeaf7..36d5a0c7 100644 --- a/components/item-act.js +++ b/components/item-act.js @@ -13,7 +13,7 @@ import { usePaidMutation } from './use-paid-mutation' import { ACT_MUTATION } from '@/fragments/paidAction' import { meAnonSats } from '@/lib/apollo' import { BoostItemInput } from './adv-post-form' -import { useWallet } from '../wallets/common' +import { useWallet } from '@/wallets/index' const defaultTips = [100, 1000, 10_000, 100_000] diff --git a/components/nav/common.js b/components/nav/common.js index 184c199a..0f0f1763 100644 --- a/components/nav/common.js +++ b/components/nav/common.js @@ -22,10 +22,9 @@ import SearchIcon from '../../svgs/search-line.svg' import classNames from 'classnames' import SnIcon from '@/svgs/sn.svg' import { useHasNewNotes } from '../use-has-new-notes' -import { useWallets } from '@/wallets/common' +import { useWallets } from '@/wallets/index' import SwitchAccountList, { useAccounts } from '@/components/account' import { useShowModal } from '@/components/modal' -import { unsetLocalKey as resetVaultKey } from '@/components/use-vault' export function Brand ({ className }) { return ( @@ -266,7 +265,6 @@ function LogoutObstacle ({ onClose }) { const { registration: swRegistration, togglePushSubscription } = useServiceWorker() const wallets = useWallets() const { multiAuthSignout } = useAccounts() - const { me } = useMe() return (
@@ -295,7 +293,6 @@ function LogoutObstacle ({ onClose }) { } await wallets.resetClient().catch(console.error) - await resetVaultKey(me?.id) await signOut({ callbackUrl: '/' }) }} diff --git a/components/payment.js b/components/payment.js index f5eb6e67..8bba56da 100644 --- a/components/payment.js +++ b/components/payment.js @@ -1,35 +1,13 @@ import { useCallback, useMemo } from 'react' import { useMe } from './me' import { gql, useApolloClient, useMutation } from '@apollo/client' -import { useWallet } from '@/wallets/common' +import { useWallet } from '@/wallets/index' import { FAST_POLL_INTERVAL, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants' import { INVOICE } from '@/fragments/wallet' import Invoice from '@/components/invoice' import { useFeeButton } from './fee-button' import { useShowModal } from './modal' - -export class InvoiceCanceledError extends Error { - constructor (hash, actionError) { - super(actionError ?? `invoice canceled: ${hash}`) - this.name = 'InvoiceCanceledError' - this.hash = hash - this.actionError = actionError - } -} - -export class NoAttachedWalletError extends Error { - constructor () { - super('no attached wallet found') - this.name = 'NoAttachedWalletError' - } -} - -export class InvoiceExpiredError extends Error { - constructor (hash) { - super(`invoice expired: ${hash}`) - this.name = 'InvoiceExpiredError' - } -} +import { InvoiceCanceledError, NoAttachedWalletError, InvoiceExpiredError } from '@/wallets/errors' export const useInvoice = () => { const client = useApolloClient() diff --git a/components/qr.js b/components/qr.js index 12bf5a93..8b3f4563 100644 --- a/components/qr.js +++ b/components/qr.js @@ -2,7 +2,7 @@ import { QRCodeSVG } from 'qrcode.react' import { CopyInput, InputSkeleton } from './form' import InvoiceStatus from './invoice-status' import { useEffect } from 'react' -import { useWallet } from '@/wallets/common' +import { useWallet } from '@/wallets/index' import Bolt11Info from './bolt11-info' export default function Qr ({ asIs, value, useWallet: automated, statusVariant, description, status }) { diff --git a/components/use-indexeddb.js b/components/use-indexeddb.js index 948440a4..5086ff69 100644 --- a/components/use-indexeddb.js +++ b/components/use-indexeddb.js @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react' -export function getDbName (userId) { - return `app:storage${userId ? `:${userId}` : ''}` +export function getDbName (userId, name) { + return `app:storage:${userId ?? ''}${name ? `:${name}` : ''}` } function useIndexedDB ({ dbName, storeName, options = { keyPath: 'id', autoIncrement: true }, indices = [], version = 1 }) { diff --git a/components/vault/use-vault-configurator.js b/components/vault/use-vault-configurator.js index ec5d849c..5056208d 100644 --- a/components/vault/use-vault-configurator.js +++ b/components/vault/use-vault-configurator.js @@ -1,11 +1,10 @@ -import { UPDATE_VAULT_KEY } from '@/fragments/users' 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 { E_VAULT_KEY_EXISTS } from '@/lib/error' -import { CLEAR_VAULT, GET_VAULT_ENTRIES } from '@/fragments/vault' +import { CLEAR_VAULT, GET_VAULT_ENTRIES, UPDATE_VAULT_KEY } from '@/fragments/vault' import { toHex } from '@/lib/hex' import { decryptData, encryptData } from './use-vault' @@ -22,7 +21,7 @@ const useImperativeQuery = (query) => { export function useVaultConfigurator () { const { me } = useMe() const toaster = useToast() - const { set, get, remove } = useIndexedDB({ dbName: getDbName(me?.id), storeName: 'vault' }) + const { set, get, remove } = useIndexedDB({ dbName: getDbName(me?.id, 'vault'), storeName: 'vault' }) const [updateVaultKey] = useMutation(UPDATE_VAULT_KEY) const getVaultEntries = useImperativeQuery(GET_VAULT_ENTRIES) const [key, setKey] = useState(null) @@ -44,10 +43,10 @@ export function useVaultConfigurator () { } setKey(localVaultKey) } catch (e) { - toaster.danger('error loading vault configuration ' + e.message) + // toaster?.danger('error loading vault configuration ' + e.message) } })() - }, [me?.privates?.vaultKeyHash, keyHash, get, remove]) + }, [me?.privates?.vaultKeyHash, keyHash, get, remove, toaster]) // clear vault: remove everything and reset the key const [clearVault] = useMutation(CLEAR_VAULT, { diff --git a/components/wallet-buttonbar.js b/components/wallet-buttonbar.js index e07995f3..76b60e92 100644 --- a/components/wallet-buttonbar.js +++ b/components/wallet-buttonbar.js @@ -1,6 +1,7 @@ import { Button } from 'react-bootstrap' import CancelButton from './cancel-button' import { SubmitButton } from './form' +import { isConfigured } from '@/wallets/common' export default function WalletButtonBar ({ wallet, disable, @@ -10,12 +11,12 @@ export default function WalletButtonBar ({ return (
- {wallet.hasConfig && wallet.isConfigured && + {isConfigured(wallet) && } {children}
{hasCancel && } - {wallet.isConfigured ? editText : createText} + {isConfigured(wallet) ? editText : createText}
diff --git a/components/wallet-card.js b/components/wallet-card.js index 7f2ae297..36328920 100644 --- a/components/wallet-card.js +++ b/components/wallet-card.js @@ -3,26 +3,18 @@ import styles from '@/styles/wallet.module.css' import Plug from '@/svgs/plug.svg' import Gear from '@/svgs/settings-5-fill.svg' import Link from 'next/link' -import { Status } from '@/wallets/common' +import { Status, isConfigured } from '@/wallets/common' import DraggableIcon from '@/svgs/draggable.svg' export default function WalletCard ({ wallet, draggable, onDragStart, onDragEnter, onDragEnd, onTouchStart, sourceIndex, targetIndex, index }) { - const { card: { title, badges } } = wallet + const { card: { title, badges } } = wallet.def let indicator = styles.disabled switch (wallet.status) { case Status.Enabled: - case true: indicator = styles.success break - case Status.Locked: - indicator = styles.warning - break - case Status.Error: - indicator = styles.error - break - case Status.Initialized: - case false: + default: indicator = styles.disabled break } @@ -57,9 +49,9 @@ export default function WalletCard ({ wallet, draggable, onDragStart, onDragEnte )} - + - {wallet.isConfigured + {isConfigured(wallet) ? <>configure : <>attach} diff --git a/components/wallet-logger.js b/components/wallet-logger.js index f9814c80..19e23f57 100644 --- a/components/wallet-logger.js +++ b/components/wallet-logger.js @@ -86,10 +86,14 @@ const INDICES = [ { name: 'wallet_ts', keyPath: ['wallet', 'ts'] } ] +function getWalletLogDbName (userId) { + return getDbName(userId) +} + function useWalletLogDB () { const { me } = useMe() const { add, getPage, clear, error, notSupported } = useIndexedDB({ - dbName: getDbName(me?.id), + dbName: getWalletLogDbName(me?.id), storeName: 'wallet_logs', indices: INDICES }) @@ -127,7 +131,7 @@ export function useWalletLogger (wallet, setLogs) { ) const deleteLogs = useCallback(async (wallet, options) => { - if ((!wallet || wallet.walletType) && !options?.clientOnly) { + if ((!wallet || wallet.def.walletType) && !options?.clientOnly) { await deleteServerWalletLogs({ variables: { wallet: wallet?.walletType } }) } if (!wallet || wallet.sendPayment) { @@ -190,7 +194,7 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) { result = await getPage(page, pageSize, indexName, query, 'prev') // no walletType means we're using the local IDB - if (wallet && !wallet.walletType) { + if (wallet && !wallet.def.walletType) { return result } } diff --git a/pages/_app.js b/pages/_app.js index 95f2a446..d138e412 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -22,7 +22,7 @@ import dynamic from 'next/dynamic' import { HasNewNotesProvider } from '@/components/use-has-new-notes' import { WebLnProvider } from '@/wallets/webln/client' import { AccountProvider } from '@/components/account' -import { WalletProvider } from '@/wallets/common' +import { WalletsProvider } from '@/wallets/index' const PWAPrompt = dynamic(() => import('react-ios-pwa-prompt'), { ssr: false }) @@ -105,7 +105,7 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - + @@ -132,7 +132,7 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - + diff --git a/pages/settings/wallets/[wallet].js b/pages/settings/wallets/[wallet].js index 80f41465..196dffc2 100644 --- a/pages/settings/wallets/[wallet].js +++ b/pages/settings/wallets/[wallet].js @@ -5,14 +5,13 @@ import { WalletSecurityBanner } from '@/components/banners' import { WalletLogs } from '@/components/wallet-logger' import { useToast } from '@/components/toast' import { useRouter } from 'next/router' -import { useWallet } from '@/wallets/common' +import { useWallet } from '@/wallets/index' import Info from '@/components/info' import Text from '@/components/text' import { AutowithdrawSettings } from '@/components/autowithdraw-shared' -import dynamic from 'next/dynamic' -import { useIsClient } from '@/components/use-client' - -const WalletButtonBar = dynamic(() => import('@/components/wallet-buttonbar.js'), { ssr: false }) +import { isConfigured } from '@/wallets/common' +import { SSR } from '@/lib/constants' +import WalletButtonBar from '@/components/wallet-buttonbar' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) @@ -22,7 +21,7 @@ export default function WalletSettings () { const { wallet: name } = router.query const wallet = useWallet(name) - const initial = wallet?.fields.reduce((acc, field) => { + const initial = wallet?.def.fields.reduce((acc, field) => { // We still need to run over all wallet fields via reduce // even though we use wallet.config as the initial value // since wallet.config is empty when wallet is not configured. @@ -41,8 +40,8 @@ export default function WalletSettings () { return ( -

{wallet?.card?.title}

-
{wallet?.card?.subtitle}
+

{wallet?.def.card.title}

+
{wallet?.def.card.subtitle}
{wallet?.canSend && wallet?.hasConfig > 0 && }
{ try { - const newConfig = !wallet?.isConfigured + const newConfig = !isConfigured(wallet) // enable wallet if wallet was just configured if (newConfig) { @@ -68,11 +67,11 @@ export default function WalletSettings () { }} > {wallet && } - {wallet?.clientOnly + {wallet?.def.clientOnly ? ( { const rawProps = { ...props, name, - initialValue: config?.[name], - readOnly: isClient && isConfigured && editable === false && !!config?.[name], + initialValue: wallet.config?.[name], + readOnly: !SSR && isConfigured(wallet) && editable === false && !!wallet.config?.[name], groupClassName: props.hidden ? 'd-none' : undefined, label: label ? ( diff --git a/pages/settings/wallets/index.js b/pages/settings/wallets/index.js index 7712fd2f..bd9a6cad 100644 --- a/pages/settings/wallets/index.js +++ b/pages/settings/wallets/index.js @@ -2,12 +2,10 @@ import { getGetServerSideProps } from '@/api/ssrApollo' import Layout from '@/components/layout' import styles from '@/styles/wallet.module.css' import Link from 'next/link' -import { useWallets, walletPrioritySort } from '@/wallets/common' +import { useWallets } from '@/wallets/index' import { useState } from 'react' -import dynamic from 'next/dynamic' import { useIsClient } from '@/components/use-client' - -const WalletCard = dynamic(() => import('@/components/wallet-card'), { ssr: false }) +import WalletCard from '@/components/wallet-card' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) @@ -28,7 +26,7 @@ async function reorder (wallets, sourceIndex, targetIndex) { } export default function Wallet ({ ssrData }) { - const { wallets } = useWallets() + const wallets = useWallets() const isClient = useIsClient() const [sourceIndex, setSourceIndex] = useState(null) @@ -76,49 +74,33 @@ export default function Wallet ({ ssrData }) {
- {wallets - .sort((w1, w2) => { - // enabled/configured wallets always come before disabled/unconfigured wallets - if ((w1.enabled && !w2.enabled) || (w1.isConfigured && !w2.isConfigured)) { - return -1 - } else if ((w2.enabled && !w1.enabled) || (w2.isConfigured && !w1.isConfigured)) { - return 1 - } + {wallets.map((w, i) => { + const draggable = isClient && w.config?.enabled - return walletPrioritySort(w1, w2) - }) - .map((w, i) => { - const draggable = isClient && w.enabled - - return ( -
- -
- ) - } - )} + suppressHydrationWarning + > + +
+ ) + } + )}
diff --git a/wallets/common.js b/wallets/common.js index 07124483..b6416fd5 100644 --- a/wallets/common.js +++ b/wallets/common.js @@ -1,10 +1,8 @@ import walletDefs from 'wallets/client' export const Status = { - Initialized: 'Initialized', Enabled: 'Enabled', - Locked: 'Locked', - Error: 'Error' + Disabled: 'Disabled' } export function getWalletByName (name) { @@ -28,13 +26,20 @@ export function getStorageKey (name, me) { } export function walletPrioritySort (w1, w2) { - const delta = w1.priority - w2.priority + // enabled/configured wallets always come before disabled/unconfigured wallets + if ((w1.config?.enabled && !w2.config?.enabled) || (isConfigured(w1) && !isConfigured(w2))) { + return -1 + } else if ((w2.config?.enabled && !w1.config?.enabled) || (isConfigured(w2) && !isConfigured(w1))) { + return 1 + } + + const delta = w1.config?.priority - w2.config?.priority // delta is NaN if either priority is undefined if (!Number.isNaN(delta) && delta !== 0) return delta // if one wallet has a priority but the other one doesn't, the one with the priority comes first - if (w1.priority !== undefined && w2.priority === undefined) return -1 - if (w1.priority === undefined && w2.priority !== undefined) return 1 + if (w1.config?.priority !== undefined && w2.config?.priority === undefined) return -1 + if (w1.config?.priority === undefined && w2.config?.priority !== undefined) return 1 // both wallets have no priority set, falling back to other methods @@ -43,5 +48,50 @@ export function walletPrioritySort (w1, w2) { if (w1.config?.id && w2.config?.id) return Number(w1.config.id) - Number(w2.config.id) // else we will use the card title as tie breaker - return w1.card.title < w2.card.title ? -1 : 1 + return w1.def.card.title < w2.def.card.title ? -1 : 1 +} + +export function isServerField (f) { + return f.serverOnly || !f.clientOnly +} + +export function isClientField (f) { + return f.clientOnly || !f.serverOnly +} + +function checkFields ({ fields, config }) { + // a wallet is configured if all of its required fields are set + let val = fields.every(f => { + return f.optional ? true : !!config?.[f.name] + }) + + // however, a wallet is not configured if all fields are optional and none are set + // since that usually means that one of them is required + if (val && fields.length > 0) { + val = !(fields.every(f => f.optional) && fields.every(f => !config?.[f.name])) + } + + return val +} + +export function isConfigured (wallet) { + return isSendConfigured(wallet) || isReceiveConfigured(wallet) +} + +function isSendConfigured (wallet) { + const fields = wallet.def.fields.filter(isClientField) + return checkFields({ fields, config: wallet.config }) +} + +function isReceiveConfigured (wallet) { + const fields = wallet.def.fields.filter(isServerField) + return checkFields({ fields, config: wallet.config }) +} + +export function canSend (wallet) { + return !!wallet.def.sendPayment && isSendConfigured(wallet) +} + +export function canReceive (wallet) { + return !wallet.def.clientOnly && isReceiveConfigured(wallet) } diff --git a/wallets/config.js b/wallets/config.js index 05cc3cdf..7e941b84 100644 --- a/wallets/config.js +++ b/wallets/config.js @@ -1,7 +1,7 @@ import { useMe } from '@/components/me' -import useVault from '@/components/use-vault' +import useVault from '@/components/vault/use-vault' import { useCallback } from 'react' -import { getStorageKey } from './common' +import { getStorageKey, isClientField, isServerField } from './common' import { useMutation } from '@apollo/client' import { generateMutation } from './graphql' import { REMOVE_WALLET } from '@/fragments/wallet' @@ -11,8 +11,8 @@ import { useWalletLogger } from '@/components/wallet-logger' export function useWalletConfigurator (wallet) { const { me } = useMe() const { encrypt, isActive } = useVault() - const { logger } = useWalletLogger(wallet.def) - const [upsertWallet] = useMutation(generateMutation(wallet.def)) + const { logger } = useWalletLogger(wallet?.def) + const [upsertWallet] = useMutation(generateMutation(wallet?.def)) const [removeWallet] = useMutation(REMOVE_WALLET) const _saveToServer = useCallback(async (serverConfig, clientConfig) => { @@ -117,48 +117,3 @@ function extractClientConfig (fields, config) { function extractServerConfig (fields, config) { return extractConfig(fields, config, false, true) } - -export function isServerField (f) { - return f.serverOnly || !f.clientOnly -} - -export function isClientField (f) { - return f.clientOnly || !f.serverOnly -} - -function checkFields ({ fields, config }) { - // a wallet is configured if all of its required fields are set - let val = fields.every(f => { - return f.optional ? true : !!config?.[f.name] - }) - - // however, a wallet is not configured if all fields are optional and none are set - // since that usually means that one of them is required - if (val && fields.length > 0) { - val = !(fields.every(f => f.optional) && fields.every(f => !config?.[f.name])) - } - - return val -} - -export function isConfigured (wallet) { - return isSendConfigured(wallet) || isReceiveConfigured(wallet) -} - -function isSendConfigured (wallet) { - const fields = wallet.def.fields.filter(isClientField) - return checkFields({ fields, config: wallet.config }) -} - -function isReceiveConfigured (wallet) { - const fields = wallet.def.fields.filter(isServerField) - return checkFields({ fields, config: wallet.config }) -} - -export function canSend (wallet) { - return !!wallet.def.sendPayment && isSendConfigured(wallet) -} - -export function canReceive (wallet) { - return !wallet.def.clientOnly && isReceiveConfigured(wallet) -} diff --git a/wallets/errors.js b/wallets/errors.js new file mode 100644 index 00000000..5cfde92b --- /dev/null +++ b/wallets/errors.js @@ -0,0 +1,22 @@ +export class InvoiceCanceledError extends Error { + constructor (hash, actionError) { + super(actionError ?? `invoice canceled: ${hash}`) + this.name = 'InvoiceCanceledError' + this.hash = hash + this.actionError = actionError + } +} + +export class NoAttachedWalletError extends Error { + constructor () { + super('no attached wallet found') + this.name = 'NoAttachedWalletError' + } +} + +export class InvoiceExpiredError extends Error { + constructor (hash) { + super(`invoice expired: ${hash}`) + this.name = 'InvoiceExpiredError' + } +} diff --git a/wallets/graphql.js b/wallets/graphql.js index 4fe0d31f..36bd6710 100644 --- a/wallets/graphql.js +++ b/wallets/graphql.js @@ -1,5 +1,5 @@ import gql from 'graphql-tag' -import { isServerField } from './config' +import { isServerField } from './common' export function fieldToGqlArg (field) { let arg = `${field.name}: String` diff --git a/wallets/index.js b/wallets/index.js index 4f466e0b..95da558d 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -3,12 +3,11 @@ import { WALLETS } from '@/fragments/wallet' import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants' import { useQuery } from '@apollo/client' import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' -import { getStorageKey, getWalletByType } from './common' -import useVault from '@/components/use-vault' +import { getStorageKey, getWalletByType, Status, walletPrioritySort, canSend } from './common' +import useVault from '@/components/vault/use-vault' import { useWalletLogger } from '@/components/wallet-logger' import { bolt11Tags } from '@/lib/bolt11' import walletDefs from 'wallets/client' -import { canSend } from './config' const WalletsContext = createContext({ wallets: [] @@ -47,6 +46,8 @@ function useLocalWallets () { return wallets } +const walletDefsOnly = walletDefs.map(w => ({ def: w, config: {} })) + export function WalletsProvider ({ children }) { const { me } = useMe() const { decrypt } = useVault() @@ -70,16 +71,19 @@ export function WalletsProvider ({ children }) { } return { config, def } - }) + }) ?? [] // merge wallets on name const merged = {} - for (const wallet of [...localWallets, ...wallets]) { + for (const wallet of [...walletDefsOnly, ...localWallets, ...wallets]) { merged[wallet.def.name] = { ...merged[wallet.def.name], ...wallet } } return Object.values(merged) + .sort(walletPrioritySort) + .map(w => ({ ...w, status: w.config?.enabled ? Status.Enabled : Status.Disabled })) }, [data?.wallets, localWallets]) + // provides priority sorted wallets to children return ( {children} @@ -101,10 +105,10 @@ export function useWallet (name) { return wallets .filter(w => !w.def.isAvailable || w.def.isAvailable()) - .filter(w => w.config.enabled && canSend(w))[0] + .filter(w => w.config?.enabled && canSend(w))[0] }, [wallets, name]) - const { logger } = useWalletLogger(wallet.def) + const { logger } = useWalletLogger(wallet?.def) const sendPayment = useCallback(async (bolt11) => { const hash = bolt11Tags(bolt11).payment_hash diff --git a/wallets/lnc/client.js b/wallets/lnc/client.js index 46371866..4e6b1fe4 100644 --- a/wallets/lnc/client.js +++ b/wallets/lnc/client.js @@ -1,4 +1,4 @@ -import { InvoiceCanceledError, InvoiceExpiredError } from '@/components/payment' +import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors' import { bolt11Tags } from '@/lib/bolt11' import { Mutex } from 'async-mutex' export * from 'wallets/lnc' From 2bdbb433df8c36d848d00a6a423a5c5d2df4eb8f Mon Sep 17 00:00:00 2001 From: k00b Date: Wed, 23 Oct 2024 17:17:35 -0500 Subject: [PATCH 39/58] webln saves at least *double kazoo* --- components/nav/common.js | 2 +- components/wallet-logger.js | 28 ++++++++++++------------ lib/validate.js | 6 ++--- pages/settings/wallets/[wallet].js | 12 +++++----- pages/settings/wallets/index.js | 2 +- wallets/common.js | 6 ++--- wallets/config.js | 35 +++++++++++++++++------------- wallets/index.js | 28 ++++++++---------------- wallets/server.js | 4 ++-- 9 files changed, 59 insertions(+), 64 deletions(-) diff --git a/components/nav/common.js b/components/nav/common.js index 0f0f1763..9574c50d 100644 --- a/components/nav/common.js +++ b/components/nav/common.js @@ -263,7 +263,7 @@ export default function LoginButton () { function LogoutObstacle ({ onClose }) { const { registration: swRegistration, togglePushSubscription } = useServiceWorker() - const wallets = useWallets() + const { wallets } = useWallets() const { multiAuthSignout } = useAccounts() return ( diff --git a/components/wallet-logger.js b/components/wallet-logger.js index 19e23f57..ca291c97 100644 --- a/components/wallet-logger.js +++ b/components/wallet-logger.js @@ -132,7 +132,7 @@ export function useWalletLogger (wallet, setLogs) { const deleteLogs = useCallback(async (wallet, options) => { if ((!wallet || wallet.def.walletType) && !options?.clientOnly) { - await deleteServerWalletLogs({ variables: { wallet: wallet?.walletType } }) + await deleteServerWalletLogs({ variables: { wallet: wallet?.def.walletType } }) } if (!wallet || wallet.sendPayment) { try { @@ -163,13 +163,13 @@ export function useWalletLogger (wallet, setLogs) { ok: (...message) => log('ok')(message.join(' ')), info: (...message) => log('info')(message.join(' ')), error: (...message) => log('error')(message.join(' ')) - }), [log, wallet?.name]) + }), [log]) return { logger, deleteLogs } } -function tag (wallet) { - return wallet?.shortName || wallet?.name +function tag (walletDef) { + return walletDef.shortName || walletDef.name } export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) { @@ -183,24 +183,24 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) { const { getPage, error, notSupported } = useWalletLogDB() const [getWalletLogs] = useLazyQuery(WALLET_LOGS, SSR ? {} : { fetchPolicy: 'cache-and-network' }) - const loadLogsPage = useCallback(async (page, pageSize, wallet) => { + const loadLogsPage = useCallback(async (page, pageSize, walletDef) => { try { let result = { data: [], hasMore: false } if (notSupported) { console.log('cannot get client wallet logs: indexeddb not supported') } else { - const indexName = wallet ? 'wallet_ts' : 'ts' - const query = wallet ? window.IDBKeyRange.bound([tag(wallet), -Infinity], [tag(wallet), Infinity]) : null + const indexName = walletDef ? 'wallet_ts' : 'ts' + const query = walletDef ? window.IDBKeyRange.bound([tag(walletDef), -Infinity], [tag(walletDef), Infinity]) : null result = await getPage(page, pageSize, indexName, query, 'prev') // no walletType means we're using the local IDB - if (wallet && !wallet.def.walletType) { + if (!walletDef?.walletType) { return result } } const { data } = await getWalletLogs({ variables: { - type: wallet?.walletType, + type: walletDef.walletType, // if it client logs has more, page based on it's range from: result?.data[result.data.length - 1]?.ts && result.hasMore ? String(result.data[result.data.length - 1].ts) : null, // if we have a cursor (this isn't the first page), page based on it's range @@ -231,28 +231,28 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) { const loadMore = useCallback(async () => { if (hasMore) { setLoading(true) - const result = await loadLogsPage(page + 1, logsPerPage, wallet) + const result = await loadLogsPage(page + 1, logsPerPage, wallet?.def) setLogs(prevLogs => [...prevLogs, ...result.data]) setHasMore(result.hasMore) setTotal(result.total) setPage(prevPage => prevPage + 1) setLoading(false) } - }, [loadLogsPage, page, logsPerPage, wallet, hasMore]) + }, [loadLogsPage, page, logsPerPage, wallet?.def, hasMore]) const loadLogs = useCallback(async () => { setLoading(true) - const result = await loadLogsPage(1, logsPerPage, wallet) + const result = await loadLogsPage(1, logsPerPage, wallet?.def) setLogs(result.data) setHasMore(result.hasMore) setTotal(result.total) setPage(1) setLoading(false) - }, [wallet, loadLogsPage]) + }, [wallet?.def, loadLogsPage]) useEffect(() => { loadLogs() - }, [wallet]) + }, [wallet?.def]) return { logs, hasMore, total, loadMore, loadLogs, setLogs, loading } } diff --git a/lib/validate.js b/lib/validate.js index 7fbadd34..3ca29757 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -43,10 +43,10 @@ export async function formikValidate (validate, data) { } export async function walletValidate (wallet, data) { - if (typeof wallet.fieldValidation === 'function') { - return await formikValidate(wallet.fieldValidation, data) + if (typeof wallet.def.fieldValidation === 'function') { + return await formikValidate(wallet.def.fieldValidation, data) } else { - return await ssValidate(wallet.fieldValidation, data) + return await ssValidate(wallet.def.fieldValidation, data) } } diff --git a/pages/settings/wallets/[wallet].js b/pages/settings/wallets/[wallet].js index 196dffc2..a70cefba 100644 --- a/pages/settings/wallets/[wallet].js +++ b/pages/settings/wallets/[wallet].js @@ -9,9 +9,10 @@ import { useWallet } from '@/wallets/index' import Info from '@/components/info' import Text from '@/components/text' import { AutowithdrawSettings } from '@/components/autowithdraw-shared' -import { isConfigured } from '@/wallets/common' +import { canSend, isConfigured } from '@/wallets/common' import { SSR } from '@/lib/constants' import WalletButtonBar from '@/components/wallet-buttonbar' +import { useWalletConfigurator } from '@/wallets/config' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) @@ -20,6 +21,7 @@ export default function WalletSettings () { const router = useRouter() const { wallet: name } = router.query const wallet = useWallet(name) + const { save, detach } = useWalletConfigurator(wallet) const initial = wallet?.def.fields.reduce((acc, field) => { // We still need to run over all wallet fields via reduce @@ -42,7 +44,7 @@ export default function WalletSettings () {

{wallet?.def.card.title}

{wallet?.def.card.subtitle}
- {wallet?.canSend && wallet?.hasConfig > 0 && } + {canSend(wallet) && } { try { - await wallet?.delete() + await detach() toaster.success('saved settings') router.push('/settings/wallets') } catch (err) { @@ -101,8 +103,6 @@ export default function WalletSettings () { } function WalletFields ({ wallet }) { - console.log('wallet', wallet) - return wallet.def.fields .map(({ name, label = '', type, help, optional, editable, clientOnly, serverOnly, ...props }, i) => { const rawProps = { diff --git a/pages/settings/wallets/index.js b/pages/settings/wallets/index.js index bd9a6cad..54a50cb7 100644 --- a/pages/settings/wallets/index.js +++ b/pages/settings/wallets/index.js @@ -26,7 +26,7 @@ async function reorder (wallets, sourceIndex, targetIndex) { } export default function Wallet ({ ssrData }) { - const wallets = useWallets() + const { wallets } = useWallets() const isClient = useIsClient() const [sourceIndex, setSourceIndex] = useState(null) diff --git a/wallets/common.js b/wallets/common.js index b6416fd5..877f49ed 100644 --- a/wallets/common.js +++ b/wallets/common.js @@ -13,13 +13,13 @@ export function getWalletByType (type) { return walletDefs.find(def => def.walletType === type) } -export function getStorageKey (name, me) { +export function getStorageKey (name, userId) { let storageKey = `wallet:${name}` // WebLN has no credentials we need to scope to users // so we can use the same storage key for all users - if (me && name !== 'webln') { - storageKey = `${storageKey}:${me.id}` + if (userId && name !== 'webln') { + storageKey = `${storageKey}:${userId}` } return storageKey diff --git a/wallets/config.js b/wallets/config.js index 7e941b84..e8d51a9f 100644 --- a/wallets/config.js +++ b/wallets/config.js @@ -1,15 +1,17 @@ import { useMe } from '@/components/me' import useVault from '@/components/vault/use-vault' import { useCallback } from 'react' -import { getStorageKey, isClientField, isServerField } from './common' +import { canReceive, canSend, getStorageKey, isClientField, isServerField } from './common' import { useMutation } from '@apollo/client' import { generateMutation } from './graphql' import { REMOVE_WALLET } from '@/fragments/wallet' import { walletValidate } from '@/lib/validate' import { useWalletLogger } from '@/components/wallet-logger' +import { useWallets } from '.' export function useWalletConfigurator (wallet) { const { me } = useMe() + const { reloadLocalWallets } = useWallets() const { encrypt, isActive } = useVault() const { logger } = useWalletLogger(wallet?.def) const [upsertWallet] = useMutation(generateMutation(wallet?.def)) @@ -26,26 +28,29 @@ export function useWalletConfigurator (wallet) { }, [encrypt, isActive]) const _saveToLocal = useCallback(async (newConfig) => { - window.localStorage.setItem(getStorageKey(wallet.name, me), JSON.stringify(newConfig)) - }, [me, wallet.name]) + window.localStorage.setItem(getStorageKey(wallet.def.name, me?.id), JSON.stringify(newConfig)) + reloadLocalWallets() + }, [me?.id, wallet.def.name, reloadLocalWallets]) const save = useCallback(async (newConfig, validate = true) => { let clientConfig = extractClientConfig(wallet.def.fields, newConfig) let serverConfig = extractServerConfig(wallet.def.fields, newConfig) if (validate) { - if (clientConfig) { + if (canSend(wallet)) { let transformedConfig = await walletValidate(wallet, clientConfig) if (transformedConfig) { clientConfig = Object.assign(clientConfig, transformedConfig) } - transformedConfig = await wallet.def.testSendPayment(clientConfig, { me, logger }) - if (transformedConfig) { - clientConfig = Object.assign(clientConfig, transformedConfig) + if (wallet.def.testSendPayment) { + transformedConfig = await wallet.def.testSendPayment(clientConfig, { me, logger }) + if (transformedConfig) { + clientConfig = Object.assign(clientConfig, transformedConfig) + } } } - if (serverConfig) { + if (canReceive(wallet)) { const transformedConfig = await walletValidate(wallet, serverConfig) if (transformedConfig) { serverConfig = Object.assign(serverConfig, transformedConfig) @@ -57,14 +62,14 @@ export function useWalletConfigurator (wallet) { if (isActive) { await _saveToServer(serverConfig, clientConfig) } else { - if (clientConfig) { + if (canSend(wallet)) { await _saveToLocal(clientConfig) } - if (serverConfig) { + if (canReceive(wallet)) { await _saveToServer(serverConfig) } } - }, [wallet.def, encrypt, isActive]) + }, [wallet, encrypt, isActive]) const _detachFromServer = useCallback(async () => { await removeWallet({ variables: { id: wallet.config.id } }) @@ -72,8 +77,8 @@ export function useWalletConfigurator (wallet) { const _detachFromLocal = useCallback(async () => { // if vault is not active and has a client config, delete from local storage - window.localStorage.removeItem(getStorageKey(wallet.name, me)) - }, [me, wallet.name]) + window.localStorage.removeItem(getStorageKey(wallet.def.name, me?.id)) + }, [me?.id, wallet.def.name]) const detach = useCallback(async () => { if (isActive) { @@ -87,7 +92,7 @@ export function useWalletConfigurator (wallet) { } }, [isActive, _detachFromServer, _detachFromLocal]) - return [save, detach] + return { save, detach } } function extractConfig (fields, config, client, includeMeta = true) { @@ -111,7 +116,7 @@ function extractConfig (fields, config, client, includeMeta = true) { } function extractClientConfig (fields, config) { - return extractConfig(fields, config, true, false) + return extractConfig(fields, config, true, true) } function extractServerConfig (fields, config) { diff --git a/wallets/index.js b/wallets/index.js index 95da558d..8b1ff6b2 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -1,6 +1,6 @@ import { useMe } from '@/components/me' import { WALLETS } from '@/fragments/wallet' -import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants' +import { LONG_POLL_INTERVAL, 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' @@ -21,44 +21,34 @@ function useLocalWallets () { // form wallets into a list of { config, def } const wallets = walletDefs.map(w => { try { - const config = window.localStorage.getItem(getStorageKey(w.name, me)) + const config = window.localStorage.getItem(getStorageKey(w.name, me?.id)) return { def: w, config: JSON.parse(config) } } catch (e) { return null } }).filter(Boolean) setWallets(wallets) - }, [me, setWallets]) + }, [me?.id, setWallets]) - // watch for changes to local storage useEffect(() => { loadWallets() - // reload wallets if local storage to wallet changes - const handler = (event) => { - if (event.key.startsWith('wallet:')) { - loadWallets() - } - } - window.addEventListener('storage', handler) - return () => window.removeEventListener('storage', handler) }, [loadWallets]) - return wallets + return { wallets, reloadLocalWallets: loadWallets } } const walletDefsOnly = walletDefs.map(w => ({ def: w, config: {} })) export function WalletsProvider ({ children }) { - const { me } = useMe() const { decrypt } = useVault() - const localWallets = useLocalWallets() + 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: NORMAL_POLL_INTERVAL, + pollInterval: LONG_POLL_INTERVAL, nextFetchPolicy: 'cache-and-network', - skip: !me?.id || SSR + skip: SSR }) const wallets = useMemo(() => { @@ -85,7 +75,7 @@ export function WalletsProvider ({ children }) { // provides priority sorted wallets to children return ( - + {children} ) @@ -96,7 +86,7 @@ export function useWallets () { } export function useWallet (name) { - const wallets = useWallets() + const { wallets } = useWallets() const wallet = useMemo(() => { if (name) { diff --git a/wallets/server.js b/wallets/server.js index 12dc2ce0..09624ecb 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -39,14 +39,14 @@ export async function createInvoice (userId, { msats, description, descriptionHa msats = toPositiveNumber(msats) for (const wallet of wallets) { - const w = walletDefs.find(w => w.walletType === wallet.type) + const w = walletDefs.find(w => w.walletType === wallet.def.walletType) try { const { walletType, walletField, createInvoice } = w const walletFull = await models.wallet.findFirst({ where: { userId, - type: walletType + type: wallet.def.walletType }, include: { [walletField]: true From 4826ae5a7b6ffaa9bc3f4a627c6522606bd87c70 Mon Sep 17 00:00:00 2001 From: k00b Date: Wed, 23 Oct 2024 20:12:43 -0500 Subject: [PATCH 40/58] wip upsertWallet --- api/resolvers/wallet.js | 34 +--- components/use-local-storage.js | 299 ----------------------------- fragments/wallet.js | 28 +-- lib/task-queue.js | 54 ------ pages/settings/wallets/[wallet].js | 34 ++-- wallets/cln/index.js | 9 +- wallets/config.js | 119 +++++++----- wallets/graphql.js | 23 +-- wallets/lightning-address/index.js | 3 +- wallets/lnc/index.js | 12 +- wallets/lnd/index.js | 9 +- 11 files changed, 149 insertions(+), 475 deletions(-) delete mode 100644 components/use-local-storage.js delete mode 100644 lib/task-queue.js diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index a0951a81..146c81fe 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -20,7 +20,6 @@ import { bolt11Tags } from '@/lib/bolt11' import { finalizeHodlInvoice } from 'worker/wallet' import walletDefs from 'wallets/server' import { generateResolverName, generateTypeDefName } from '@/wallets/graphql' -import { isConfigured } from '@/wallets/common' import { lnAddrOptions } from '@/lib/lnurl' import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error' import { getNodeSockets, getOurPubkey } from '../lnd' @@ -30,39 +29,24 @@ function injectResolvers (resolvers) { for (const w of walletDefs) { const resolverName = generateResolverName(w.walletField) console.log(resolverName) - resolvers.Mutation[resolverName] = async (parent, { settings, priorityOnly, canSend, canReceive, ...data }, { me, models }) => { - if (canReceive && !w.createInvoice) { - console.warn('Requested to upsert wallet as a receiver, but wallet does not support createInvoice. disabling') - canReceive = false + 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 }) + 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] }) } - if (!priorityOnly && canReceive) { - // check if the required fields are set - if (!isConfigured({ fields: w.fields, config: data, serverOnly: true })) { - throw new GqlInputError('missing required fields') - } - // allow transformation of the data on validation (this is optional ... won't do anything if not implemented) - const validData = await walletValidate(w, { ...data, ...settings }) - 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] }) - } - } - - if (!canReceive && !canSend) throw new GqlInputError('wallet must be able to send or receive') return await upsertWallet({ wallet: { - field: - w.walletField, + field: w.walletField, type: w.walletType }, - testCreateInvoice: w.testCreateInvoice ? (data) => w.testCreateInvoice(data, { me, models }) : null + testCreateInvoice: w.testCreateInvoice && validateLightning ? (data) => w.testCreateInvoice(data, { me, models }) : null }, { settings, data, - priorityOnly, - canSend, - canReceive + vaultEntries }, { me, models }) } } diff --git a/components/use-local-storage.js b/components/use-local-storage.js deleted file mode 100644 index c3171062..00000000 --- a/components/use-local-storage.js +++ /dev/null @@ -1,299 +0,0 @@ -import { SSR } from '@/lib/constants' -import { useMe } from './me' -import { useEffect, useRef } from 'react' -import createTaskQueue from '@/lib/task-queue' - -const VERSION = 1 - -/** - * A react hook to use the local storage - * It handles the lifecycle of the storage, opening and closing it as needed. - * - * @param {*} options - * @param {string} options.database - the database name - * @param {[string]} options.namespace - the namespace of the storage - * @returns {[object]} - the local storage - */ -export default function useLocalStorage ({ database = 'default', namespace = ['default'] }) { - const { me } = useMe() - if (!Array.isArray(namespace)) namespace = [namespace] - const joinedNamespace = namespace.join(':') - const storage = useRef(openLocalStorage({ database, userId: me?.id, namespace })) - - useEffect(() => { - const currentStorage = storage.current - const newStorage = openLocalStorage({ database, userId: me?.id, namespace }) - storage.current = newStorage - if (currentStorage)currentStorage.close() - return () => { - newStorage.close() - } - }, [me, database, joinedNamespace]) - - return [{ - set: (key, value) => storage.current.set(key, value), - get: (key) => storage.current.get(key), - unset: (key) => storage.current.unset(key), - clear: () => storage.current.clear(), - list: () => storage.current.list() - }] -} - -/** - * Open a local storage. - * This is an abstraction on top of IndexedDB or, when not available, an in-memory storage. - * A combination of userId, database and namespace is used to efficiently separate different storage units. - * Namespaces can be an array of strings, that will be internally joined to form a single namespace. - * - * @param {*} options - * @param {string} options.userId - the user that owns the storage (anon if not provided) - * @param {string} options.database - the database name (default if not provided) - * @param {[string]} options.namespace - the namespace of the storage (default if not provided) - * @returns {object} - the local storage - * @throws Error if the namespace is invalid - */ -export function openLocalStorage ({ userId, database = 'default', namespace = ['default'] }) { - if (!userId) userId = 'anon' - if (!Array.isArray(namespace)) namespace = [namespace] - if (SSR) return createMemBackend(userId, namespace) - - let backend = newIdxDBBackend(userId, database, namespace) - - if (!backend) { - console.warn('no local storage backend available, fallback to in memory storage') - backend = createMemBackend(userId, namespace) - } - return backend -} - -export async function listLocalStorages ({ userId, database }) { - if (SSR) return [] - return await listIdxDBBackendNamespaces(userId, database) -} - -/** - * In memory storage backend (volatile/dummy storage) - */ -function createMemBackend (userId, namespace) { - const joinedNamespace = userId + ':' + namespace.join(':') - let memory - if (SSR) { - memory = {} - } else { - if (!window.snMemStorage) window.snMemStorage = {} - memory = window.snMemStorage[joinedNamespace] - if (!memory) window.snMemStorage[joinedNamespace] = memory = {} - } - return { - set: (key, value) => { memory[key] = value }, - get: (key) => memory[key], - unset: (key) => { delete memory[key] }, - clear: () => { Object.keys(memory).forEach(key => delete memory[key]) }, - list: () => Object.keys(memory), - close: () => { } - } -} - -/** - * Open an IndexedDB connection - * @param {*} userId - * @param {*} database - * @param {*} onupgradeneeded - * @param {*} queue - * @returns {object} - an open connection - * @throws Error if the connection cannot be opened - */ -async function openIdxDB (userId, database, onupgradeneeded, queue) { - const fullDbName = `${database}:${userId}` - // we keep a reference to every open indexed db connection - // to reuse them whenever possible - if (window && !window.snIdxDB) window.snIdxDB = {} - let openConnection = window?.snIdxDB?.[fullDbName] - - const close = () => { - const conn = openConnection - conn.ref-- - if (conn.ref === 0) { // close the connection for real if nothing is using it - if (window?.snIdxDB) delete window.snIdxDB[fullDbName] - queue.enqueue(() => { - conn.db.close() - }) - } - } - - // if for any reason the connection is outdated, we close it - if (openConnection && openConnection.version !== VERSION) { - close() - openConnection = undefined - } - // an open connections is not available, so we create a new one - if (!openConnection) { - openConnection = { - version: VERSION, - ref: 1, // we need a ref count to know when to close the connection for real - db: null, - close - } - openConnection.db = await new Promise((resolve, reject) => { - const request = window.indexedDB.open(fullDbName, VERSION) - request.onupgradeneeded = (event) => { - const db = event.target.result - if (onupgradeneeded) onupgradeneeded(db) - } - request.onsuccess = (event) => { - const db = event.target.result - if (!db?.transaction) reject(new Error('unsupported implementation')) - else resolve(db) - } - request.onerror = reject - }) - window.snIdxDB[fullDbName] = openConnection - } else { - // increase the reference count - openConnection.ref++ - } - return openConnection -} - -/** - * An IndexedDB based persistent storage - * @param {string} userId - the user that owns the storage - * @param {string} database - the database name - * @returns {object} - an indexedDB persistent storage - * @throws Error if the namespace is invalid - */ -function newIdxDBBackend (userId, database, namespace) { - if (!window.indexedDB) return undefined - if (!namespace) throw new Error('missing namespace') - if (!Array.isArray(namespace) || !namespace.length || namespace.find(n => !n || typeof n !== 'string')) throw new Error('invalid namespace. must be a non-empty array of strings') - if (namespace.find(n => n.includes(':'))) throw new Error('invalid namespace. must not contain ":"') - - namespace = namespace.join(':') - - const queue = createTaskQueue() - - let openConnection = null - let closed = false - const initialize = async () => { - if (!openConnection) { - openConnection = await openIdxDB(userId, database, (db) => { - db.createObjectStore(database, { keyPath: ['namespace', 'key'] }) - }, queue) - } - } - - return { - set: async (key, value) => { - await queue.enqueue(async () => { - await initialize() - const tx = openConnection.db.transaction([database], 'readwrite') - const objectStore = tx.objectStore(database) - objectStore.put({ namespace, key, value }) - await new Promise((resolve, reject) => { - tx.oncomplete = resolve - tx.onerror = reject - }) - }) - }, - get: async (key) => { - return await queue.enqueue(async () => { - await initialize() - const tx = openConnection.db.transaction([database], 'readonly') - const objectStore = tx.objectStore(database) - const request = objectStore.get([namespace, key]) - return await new Promise((resolve, reject) => { - request.onsuccess = () => resolve(request.result?.value) - request.onerror = reject - }) - }) - }, - unset: async (key) => { - await queue.enqueue(async () => { - await initialize() - const tx = openConnection.db.transaction([database], 'readwrite') - const objectStore = tx.objectStore(database) - objectStore.delete([namespace, key]) - await new Promise((resolve, reject) => { - tx.oncomplete = resolve - tx.onerror = reject - }) - }) - }, - clear: async () => { - await queue.enqueue(async () => { - await initialize() - const tx = openConnection.db.transaction([database], 'readwrite') - const objectStore = tx.objectStore(database) - objectStore.clear() - await new Promise((resolve, reject) => { - tx.oncomplete = resolve - tx.onerror = reject - }) - }) - }, - list: async () => { - return await queue.enqueue(async () => { - await initialize() - const tx = openConnection.db.transaction([database], 'readonly') - const objectStore = tx.objectStore(database) - const keys = [] - return await new Promise((resolve, reject) => { - const request = objectStore.openCursor() - request.onsuccess = (event) => { - const cursor = event.target.result - if (cursor) { - if (cursor.key[0] === namespace) { - keys.push(cursor.key[1]) // Push only the 'key' part of the composite key - } - cursor.continue() - } else { - resolve(keys) - } - } - request.onerror = reject - }) - }) - }, - close: async () => { - if (closed) return - closed = true - queue.enqueue(async () => { - if (openConnection) await openConnection.close() - }) - } - } -} - -/** - * List all the namespaces used in an IndexedDB database - * @param {*} userId - the user that owns the storage - * @param {*} database - the database name - * @returns {array} - an array of namespace names - */ -async function listIdxDBBackendNamespaces (userId, database) { - if (!window?.indexedDB) return [] - const queue = createTaskQueue() - const openConnection = await openIdxDB(userId, database, null, queue) - try { - const list = await queue.enqueue(async () => { - const objectStore = openConnection.db.transaction([database], 'readonly').objectStore(database) - const namespaces = new Set() - return await new Promise((resolve, reject) => { - const request = objectStore.openCursor() - request.onsuccess = (event) => { - const cursor = event.target.result - if (cursor) { - namespaces.add(cursor.key[0]) - cursor.continue() - } else { - resolve(Array.from(namespaces).map(n => n.split(':'))) - } - } - request.onerror = reject - }) - }) - return list - } finally { - openConnection.close() - } -} diff --git a/fragments/wallet.js b/fragments/wallet.js index 67d38559..0c880c5f 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -186,20 +186,26 @@ export const WALLET_BY_TYPE = gql` } ` +export const WALLET_FIELDS = gql` + fragment WalletFields on Wallet { + id + priority + type + updatedAt + enabled + vaultEntries { + key + value + } + } +` + export const WALLETS = gql` + ${WALLET_FIELDS} + query Wallets { wallets { - id - priority - type - updatedAt - canSend - canReceive - enabled - vaultEntries { - key - value - } + ...WalletFields } } ` diff --git a/lib/task-queue.js b/lib/task-queue.js deleted file mode 100644 index c8a98190..00000000 --- a/lib/task-queue.js +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Create a queue to run tasks sequentially - * @returns {Object} - the queue - * @returns {function} enqueue - Function to add a task to the queue - * @returns {function} lock - Function to lock the queue - * @returns {function} wait - Function to wait for the queue to be empty - */ -export default function createTaskQueue () { - const queue = { - queue: Promise.resolve(), - /** - * Enqueue a task to be run sequentially - * @param {function} fn - The task function to be enqueued - * @returns {Promise} - A promise that resolves with the result of the task function - */ - enqueue (fn) { - return new Promise((resolve, reject) => { - queue.queue = queue.queue.then(async () => { - try { - resolve(await fn()) - } catch (e) { - reject(e) - } - }) - }) - }, - /** - * Lock the queue so that it can't move forward until unlocked - * @param {boolean} [wait=true] - Whether to wait for the lock to be acquired - * @returns {Promise} - A promise that resolves with the unlock function - */ - async lock (wait = true) { - let unlock - const lock = new Promise((resolve) => { unlock = resolve }) - const locking = new Promise((resolve) => { - queue.queue = queue.queue.then(() => { - resolve() - return lock - }) - }) - if (wait) await locking - return unlock - }, - /** - * Wait for the queue to be empty - * @returns {Promise} - A promise that resolves when the queue is empty - */ - async wait () { - return queue.queue - } - } - - return queue -} diff --git a/pages/settings/wallets/[wallet].js b/pages/settings/wallets/[wallet].js index a70cefba..95de9f61 100644 --- a/pages/settings/wallets/[wallet].js +++ b/pages/settings/wallets/[wallet].js @@ -8,11 +8,13 @@ import { useRouter } from 'next/router' import { useWallet } from '@/wallets/index' import Info from '@/components/info' import Text from '@/components/text' -import { AutowithdrawSettings } from '@/components/autowithdraw-shared' +import { autowithdrawInitial, AutowithdrawSettings } from '@/components/autowithdraw-shared' import { canSend, isConfigured } from '@/wallets/common' import { SSR } from '@/lib/constants' import WalletButtonBar from '@/components/wallet-buttonbar' import { useWalletConfigurator } from '@/wallets/config' +import { useMemo } from 'react' +import { useMe } from '@/components/me' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) @@ -21,19 +23,29 @@ export default function WalletSettings () { const router = useRouter() const { wallet: name } = router.query const wallet = useWallet(name) + const { me } = useMe() const { save, detach } = useWalletConfigurator(wallet) - const initial = wallet?.def.fields.reduce((acc, field) => { - // We still need to run over all wallet fields via reduce - // even though we use wallet.config as the initial value - // since wallet.config is empty when wallet is not configured. - // Also, wallet.config includes general fields like - // 'enabled' and 'priority' which are not defined in wallet.fields. - return { - ...acc, - [field.name]: wallet?.config?.[field.name] || '' + const initial = useMemo(() => { + const initial = wallet?.def.fields.reduce((acc, field) => { + // We still need to run over all wallet fields via reduce + // even though we use wallet.config as the initial value + // since wallet.config is empty when wallet is not configured. + // Also, wallet.config includes general fields like + // 'enabled' and 'priority' which are not defined in wallet.fields. + return { + ...acc, + [field.name]: wallet?.config?.[field.name] || '' + } + }, wallet?.config) + if (wallet?.def.clientOnly) { + return initial } - }, wallet?.config) + return { + ...initial, + ...autowithdrawInitial({ me }) + } + }, [wallet, me]) // check if wallet uses the form-level validation built into Formik or a Yup schema const validateProps = typeof wallet?.fieldValidation === 'function' diff --git a/wallets/cln/index.js b/wallets/cln/index.js index ff4a1196..3ee2013a 100644 --- a/wallets/cln/index.js +++ b/wallets/cln/index.js @@ -12,7 +12,8 @@ export const fields = [ type: 'text', placeholder: '55.5.555.55:3010', hint: 'tor or clearnet', - clear: true + clear: true, + serverOnly: true }, { name: 'rune', @@ -23,7 +24,8 @@ export const fields = [ type: 'text', placeholder: 'S34KtUW-6gqS_hD_9cc_PNhfF-NinZyBOCgr1aIrark9NCZtZXRob2Q9aW52b2ljZQ==', hint: 'must be restricted to method=invoice', - clear: true + clear: true, + serverOnly: true }, { name: 'cert', @@ -32,7 +34,8 @@ export const fields = [ placeholder: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', optional: 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)', hint: 'hex or base64 encoded', - clear: true + clear: true, + serverOnly: true } ] diff --git a/wallets/config.js b/wallets/config.js index e8d51a9f..c7d6a5b9 100644 --- a/wallets/config.js +++ b/wallets/config.js @@ -1,7 +1,7 @@ import { useMe } from '@/components/me' import useVault from '@/components/vault/use-vault' import { useCallback } from 'react' -import { canReceive, canSend, getStorageKey, isClientField, isServerField } from './common' +import { canReceive, canSend, getStorageKey } from './common' import { useMutation } from '@apollo/client' import { generateMutation } from './graphql' import { REMOVE_WALLET } from '@/fragments/wallet' @@ -17,63 +17,71 @@ export function useWalletConfigurator (wallet) { const [upsertWallet] = useMutation(generateMutation(wallet?.def)) const [removeWallet] = useMutation(REMOVE_WALLET) - const _saveToServer = useCallback(async (serverConfig, clientConfig) => { + const _saveToServer = useCallback(async (serverConfig, clientConfig, validateLightning) => { + const { serverWithShared, settings, clientOnly } = siftConfig(wallet.def.fields, { ...serverConfig, ...clientConfig }) const vaultEntries = [] - if (clientConfig) { - for (const [key, value] of Object.entries(clientConfig)) { + if (clientOnly) { + for (const [key, value] of Object.entries(clientOnly)) { vaultEntries.push({ key, value: encrypt(value) }) } } - await upsertWallet({ variables: { ...serverConfig, vaultEntries } }) - }, [encrypt, isActive]) + await upsertWallet({ variables: { ...serverWithShared, settings, validateLightning, vaultEntries } }) + }, [encrypt, isActive, wallet.def.fields]) const _saveToLocal = useCallback(async (newConfig) => { window.localStorage.setItem(getStorageKey(wallet.def.name, me?.id), JSON.stringify(newConfig)) reloadLocalWallets() }, [me?.id, wallet.def.name, reloadLocalWallets]) - const save = useCallback(async (newConfig, validate = true) => { - let clientConfig = extractClientConfig(wallet.def.fields, newConfig) - let serverConfig = extractServerConfig(wallet.def.fields, newConfig) + const _validate = useCallback(async (config, validateLightning = true) => { + const { serverWithShared, clientWithShared } = siftConfig(wallet.def.fields, config) + console.log('sifted', siftConfig(wallet.def.fields, config)) - if (validate) { - if (canSend(wallet)) { - let transformedConfig = await walletValidate(wallet, clientConfig) + let clientConfig = clientWithShared + let serverConfig = serverWithShared + + if (canSend(wallet)) { + let transformedConfig = await walletValidate(wallet, clientWithShared) + if (transformedConfig) { + clientConfig = Object.assign(clientConfig, transformedConfig) + } + if (wallet.def.testSendPayment && validateLightning) { + transformedConfig = await wallet.def.testSendPayment(clientConfig, { me, logger }) if (transformedConfig) { clientConfig = Object.assign(clientConfig, transformedConfig) } - if (wallet.def.testSendPayment) { - transformedConfig = await wallet.def.testSendPayment(clientConfig, { me, logger }) - if (transformedConfig) { - clientConfig = Object.assign(clientConfig, transformedConfig) - } - } - } - - if (canReceive(wallet)) { - const transformedConfig = await walletValidate(wallet, serverConfig) - if (transformedConfig) { - serverConfig = Object.assign(serverConfig, transformedConfig) - } } } + if (canReceive(wallet)) { + const transformedConfig = await walletValidate(wallet, serverConfig) + if (transformedConfig) { + serverConfig = Object.assign(serverConfig, transformedConfig) + } + } + + return { clientConfig, serverConfig } + }, [wallet]) + + const save = useCallback(async (newConfig, validateLightning = true) => { + const { clientConfig, serverConfig } = _validate(newConfig, validateLightning) + // if vault is active, encrypt and send to server regardless of wallet type if (isActive) { - await _saveToServer(serverConfig, clientConfig) + await _saveToServer(serverConfig, clientConfig, validateLightning) } else { if (canSend(wallet)) { await _saveToLocal(clientConfig) } if (canReceive(wallet)) { - await _saveToServer(serverConfig) + await _saveToServer(serverConfig, clientConfig, validateLightning) } } - }, [wallet, encrypt, isActive]) + }, [isActive, _saveToServer, _saveToLocal, _validate]) const _detachFromServer = useCallback(async () => { await removeWallet({ variables: { id: wallet.config.id } }) - }, [wallet.config.id]) + }, [wallet.config?.id]) const _detachFromLocal = useCallback(async () => { // if vault is not active and has a client config, delete from local storage @@ -95,30 +103,45 @@ export function useWalletConfigurator (wallet) { return { save, detach } } -function extractConfig (fields, config, client, includeMeta = true) { - return Object.entries(config).reduce((acc, [key, value]) => { +function siftConfig (fields, config) { + const sifted = { + clientOnly: {}, + serverOnly: {}, + shared: {}, + serverWithShared: {}, + clientWithShared: {}, + settings: {} + } + + for (const [key, value] of Object.entries(config)) { + if (['id'].includes(key)) { + sifted.serverOnly[key] = value + continue + } + + if (['autoWithdrawMaxFeePercent', 'autoWithdrawThreshold', 'autoWithdrawMaxFeeTotal'].includes(key)) { + sifted.serverOnly[key] = value + sifted.settings[key] = value + continue + } + const field = fields.find(({ name }) => name === key) - // filter server config which isn't specified as wallet fields - // (we allow autowithdraw members to pass validation) - if (client && key === 'id') return acc - - // field might not exist because config.enabled doesn't map to a wallet field - if ((!field && includeMeta) || (field && (client ? isClientField(field) : isServerField(field)))) { - return { - ...acc, - [key]: value + if (field) { + if (field.serverOnly) { + sifted.serverOnly[key] = value + } else if (field.clientOnly) { + sifted.clientOnly[key] = value + } else { + sifted.shared[key] = value } } else { - return acc + sifted.shared[key] = value } - }, {}) -} + } -function extractClientConfig (fields, config) { - return extractConfig(fields, config, true, true) -} + sifted.serverWithShared = { ...sifted.shared, ...sifted.serverOnly } + sifted.clientWithShared = { ...sifted.shared, ...sifted.clientOnly } -function extractServerConfig (fields, config) { - return extractConfig(fields, config, false, true) + return sifted } diff --git a/wallets/graphql.js b/wallets/graphql.js index 36bd6710..cc399c32 100644 --- a/wallets/graphql.js +++ b/wallets/graphql.js @@ -1,5 +1,6 @@ import gql from 'graphql-tag' import { isServerField } from './common' +import { WALLET_FIELDS } from '@/fragments/wallet' export function fieldToGqlArg (field) { let arg = `${field.name}: String` @@ -30,30 +31,20 @@ export function generateMutation (wallet) { let headerArgs = '$id: ID, ' headerArgs += wallet.fields .filter(isServerField) - .map(f => { - const arg = `$${f.name}: String` - // required fields are checked server-side - // if (!f.optional) { - // arg += '!' - // } - return arg - }).join(', ') - headerArgs += ', $settings: AutowithdrawSettings!, $priorityOnly: Boolean, $canSend: Boolean!, $canReceive: Boolean!' + .map(f => `$${f.name}: String`) + .join(', ') + headerArgs += ', $settings: AutowithdrawSettings!, $validateLightning: Boolean' let inputArgs = 'id: $id, ' inputArgs += wallet.fields .filter(isServerField) .map(f => `${f.name}: $${f.name}`).join(', ') - inputArgs += ', settings: $settings, priorityOnly: $priorityOnly, canSend: $canSend, canReceive: $canReceive,' + inputArgs += ', settings: $settings, validateLightning: $validateLightning,' return gql`mutation ${resolverName}(${headerArgs}) { + ${WALLET_FIELDS} ${resolverName}(${inputArgs}) { - id, - type, - enabled, - priority, - canReceive, - canSend + ...WalletFields } }` } diff --git a/wallets/lightning-address/index.js b/wallets/lightning-address/index.js index cf8d5055..73cf5165 100644 --- a/wallets/lightning-address/index.js +++ b/wallets/lightning-address/index.js @@ -11,7 +11,8 @@ export const fields = [ name: 'address', label: 'lightning address', type: 'text', - autoComplete: 'off' + autoComplete: 'off', + serverOnly: true } ] diff --git a/wallets/lnc/index.js b/wallets/lnc/index.js index a834857c..58afdac6 100644 --- a/wallets/lnc/index.js +++ b/wallets/lnc/index.js @@ -12,25 +12,29 @@ export const fields = [ label: 'pairing phrase', type: 'password', help: 'We only need permissions for the uri `/lnrpc.Lightning/SendPaymentSync`\n\nCreate a budgeted account with narrow permissions:\n\n```$ litcli accounts create --balance ```\n\n```$ litcli sessions add --type custom --label --account_id --uri /lnrpc.Lightning/SendPaymentSync```\n\nGrab the `pairing_secret_mnemonic` from the output and paste it here.', - editable: false + editable: false, + clientOnly: true }, { name: 'localKey', type: 'text', optional: true, - hidden: true + hidden: true, + clientOnly: true }, { name: 'remoteKey', type: 'text', optional: true, - hidden: true + hidden: true, + clientOnly: true }, { name: 'serverHost', type: 'text', optional: true, - hidden: true + hidden: true, + clientOnly: true } ] diff --git a/wallets/lnd/index.js b/wallets/lnd/index.js index a23fec86..bed10d62 100644 --- a/wallets/lnd/index.js +++ b/wallets/lnd/index.js @@ -12,7 +12,8 @@ export const fields = [ type: 'text', placeholder: '55.5.555.55:10001', hint: 'tor or clearnet', - clear: true + clear: true, + serverOnly: true }, { name: 'macaroon', @@ -24,7 +25,8 @@ export const fields = [ type: 'text', placeholder: 'AgEDbG5kAlgDChCn7YgfWX7uTkQQgXZ2uahNEgEwGhYKB2FkZHJlc3MSBHJlYWQSBXdyaXRlGhcKCGludm9pY2VzEgRyZWFkEgV3cml0ZRoPCgdvbmNoYWluEgRyZWFkAAAGIJkMBrrDV0npU90JV0TGNJPrqUD8m2QYoTDjolaL6eBs', hint: 'hex or base64 encoded', - clear: true + clear: true, + serverOnly: true }, { name: 'cert', @@ -33,7 +35,8 @@ export const fields = [ placeholder: 'LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNNVENDQWRpZ0F3SUJBZ0lRSHBFdFdrcGJwZHV4RVF2eVBPc3NWVEFLQmdncWhrak9QUVFEQWpBdk1SOHcKSFFZRFZRUUtFeFpzYm1RZ1lYVjBiMmRsYm1WeVlYUmxaQ0JqWlhKME1Rd3dDZ1lEVlFRREV3TmliMkl3SGhjTgpNalF3TVRBM01qQXhORE0wV2hjTk1qVXdNekF6TWpBeE5ETTBXakF2TVI4d0hRWURWUVFLRXhac2JtUWdZWFYwCmIyZGxibVZ5WVhSbFpDQmpaWEowTVF3d0NnWURWUVFERXdOaWIySXdXVEFUQmdjcWhrak9QUUlCQmdncWhrak8KUFFNQkJ3TkNBQVJUS3NMVk5oZnhqb1FLVDlkVVdDbzUzSmQwTnBuL1BtYi9LUE02M1JxbU52dFYvdFk4NjJJZwpSbE41cmNHRnBEajhUeFc2OVhIK0pTcHpjWDdlN3N0Um80SFZNSUhTTUE0R0ExVWREd0VCL3dRRUF3SUNwREFUCkJnTlZIU1VFRERBS0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01CMEdBMVVkRGdRV0JCVDAKMnh3V25GeHRUNzI0MWxwZlNoNm9FWi9UMWpCN0JnTlZIUkVFZERCeWdnTmliMktDQ1d4dlkyRnNhRzl6ZElJRApZbTlpZ2d4d2IyeGhjaTF1TVMxaWIyS0NGR2h2YzNRdVpHOWphMlZ5TG1sdWRHVnlibUZzZ2dSMWJtbDRnZ3AxCmJtbDRjR0ZqYTJWMGdnZGlkV1pqYjI1dWh3Ui9BQUFCaHhBQUFBQUFBQUFBQUFBQUFBQUFBQUFCaHdTc0VnQUQKTUFvR0NDcUdTTTQ5QkFNQ0EwY0FNRVFDSUEwUTlkRXdoNXpPRnpwL3hYeHNpemh5SkxNVG5yazU1VWx1NHJPRwo4WW52QWlBVGt4U3p3Y3hZZnFscGx0UlNIbmd0NUJFcDBzcXlHL05nenBzb2pmMGNqQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K', optional: 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)', hint: 'hex or base64 encoded', - clear: true + clear: true, + serverOnly: true } ] From ccdf346954baa1b46220e7e4267d01874633fae6 Mon Sep 17 00:00:00 2001 From: k00b Date: Thu, 24 Oct 2024 15:30:56 -0500 Subject: [PATCH 41/58] server side config saves --- api/resolvers/vault.js | 4 +- api/resolvers/wallet.js | 191 +++++++++--------- api/typeDefs/user.js | 4 +- api/typeDefs/wallet.js | 6 +- components/fee-button.js | 5 +- components/items.js | 5 +- components/sub-select.js | 6 +- components/use-debounce-callback.js | 4 +- components/use-indexeddb.js | 10 +- components/user-list.js | 6 +- components/vault/use-vault-configurator.js | 7 +- components/wallet-logger.js | 10 +- fragments/users.js | 70 +------ fragments/wallet.js | 126 +++++------- lib/validate.js | 8 +- package-lock.json | 88 ++++---- package.json | 2 +- .../migration.sql | 33 ++- prisma/schema.prisma | 26 +-- wallets/common.js | 24 +-- wallets/config.js | 22 +- wallets/graphql.js | 9 +- wallets/index.js | 22 +- wallets/server.js | 8 +- 24 files changed, 325 insertions(+), 371 deletions(-) rename prisma/migrations/{20241021224248_vault => 20241024175439_vault}/migration.sql (63%) diff --git a/api/resolvers/vault.js b/api/resolvers/vault.js index 99c0486e..5915899f 100644 --- a/api/resolvers/vault.js +++ b/api/resolvers/vault.js @@ -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) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 146c81fe..c6273300 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -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 }) { diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index cfbfd98e..daeadf5c 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -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 { diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index ad697217..c0a88ee4 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -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 { diff --git a/components/fee-button.js b/components/fee-button.js index 7926dc94..194dd123 100644 --- a/components/fee-button.js +++ b/components/fee-button.js @@ -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() diff --git a/components/items.js b/components/items.js index e09c2b06..738ee764 100644 --- a/components/items.js +++ b/components/items.js @@ -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) diff --git a/components/sub-select.js b/components/sub-select.js index c4096222..7a682632 100644 --- a/components/sub-select.js +++ b/components/sub-select.js @@ -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 ? {} : { diff --git a/components/use-debounce-callback.js b/components/use-debounce-callback.js index 3bd4ffc0..7c7f5b24 100644 --- a/components/use-debounce-callback.js +++ b/components/use-debounce-callback.js @@ -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]) diff --git a/components/use-indexeddb.js b/components/use-indexeddb.js index 5086ff69..eac82872 100644 --- a/components/use-indexeddb.js +++ b/components/use-indexeddb.js @@ -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) { diff --git a/components/user-list.js b/components/user-list.js index c2c2f953..40f77286 100644 --- a/components/user-list.js +++ b/components/user-list.js @@ -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 (
{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 diff --git a/components/vault/use-vault-configurator.js b/components/vault/use-vault-configurator.js index 5056208d..f55e7cf1 100644 --- a/components/vault/use-vault-configurator.js +++ b/components/vault/use-vault-configurator.js @@ -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, { diff --git a/components/wallet-logger.js b/components/wallet-logger.js index ca291c97..11daaade 100644 --- a/components/wallet-logger.js +++ b/components/wallet-logger.js @@ -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 } } diff --git a/fragments/users.js b/fragments/users.js index 768be056..6feac837 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -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 diff --git a/fragments/wallet.js b/fragments/wallet.js index 0c880c5f..3e1a445c 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -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 diff --git a/lib/validate.js b/lib/validate.js index 3ca29757..96753c94 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -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) } } diff --git a/package-lock.json b/package-lock.json index 9d391222..d4dd4c9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6fb481e5..9ea6c48f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/migrations/20241021224248_vault/migration.sql b/prisma/migrations/20241024175439_vault/migration.sql similarity index 63% rename from prisma/migrations/20241021224248_vault/migration.sql rename to prisma/migrations/20241024175439_vault/migration.sql index dd318e1f..ca5c3ea4 100644 --- a/prisma/migrations/20241021224248_vault/migration.sql +++ b/prisma/migrations/20241024175439_vault/migration.sql @@ -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(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e9496a97..cd643e67 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) } diff --git a/wallets/common.js b/wallets/common.js index 877f49ed..76d33aa9 100644 --- a/wallets/common.js +++ b/wallets/common.js @@ -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 }) } diff --git a/wallets/config.js b/wallets/config.js index c7d6a5b9..d4312ed9 100644 --- a/wallets/config.js +++ b/wallets/config.js @@ -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]) diff --git a/wallets/graphql.js b/wallets/graphql.js index cc399c32..0fbd055d 100644 --- a/wallets/graphql.js +++ b/wallets/graphql.js @@ -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 } }` diff --git a/wallets/index.js b/wallets/index.js index 8b1ff6b2..d5b65434 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -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 diff --git a/wallets/server.js b/wallets/server.js index 09624ecb..7e004aa6 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -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 From 57603a936f569c791a582ffcda915d2ce930f861 Mon Sep 17 00:00:00 2001 From: k00b Date: Thu, 24 Oct 2024 16:35:18 -0500 Subject: [PATCH 42/58] reorder priority --- api/resolvers/wallet.js | 9 +++++ api/typeDefs/wallet.js | 1 + fragments/wallet.js | 6 ++++ pages/settings/wallets/index.js | 62 ++++++++++++++++++--------------- wallets/index.js | 30 +++++++++++++--- 5 files changed, 74 insertions(+), 34 deletions(-) diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index c6273300..05ac369e 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -525,6 +525,15 @@ const resolvers = { } return { id } }, + setWalletPriority: async (parent, { id, priority }, { me, models }) => { + if (!me) { + throw new GqlAuthenticationError() + } + + await models.wallet.update({ where: { userId: me.id, id: Number(id) }, data: { priority } }) + + return true + }, removeWallet: async (parent, { id }, { me, models }) => { if (!me) { throw new GqlAuthenticationError() diff --git a/api/typeDefs/wallet.js b/api/typeDefs/wallet.js index c0a88ee4..c26a5b9d 100644 --- a/api/typeDefs/wallet.js +++ b/api/typeDefs/wallet.js @@ -81,6 +81,7 @@ const typeDefs = ` dropBolt11(id: ID): Withdrawl removeWallet(id: ID!): Boolean deleteWalletLogs(wallet: String): Boolean + setWalletPriority(id: ID!, priority: Int!): Boolean } type Wallet { diff --git a/fragments/wallet.js b/fragments/wallet.js index 3e1a445c..10e71e3d 100644 --- a/fragments/wallet.js +++ b/fragments/wallet.js @@ -190,3 +190,9 @@ export const WALLET_LOGS = gql` } } ` + +export const SET_WALLET_PRIORITY = gql` + mutation SetWalletPriority($id: ID!, $priority: Int!) { + setWalletPriority(id: $id, priority: $priority) + } +` diff --git a/pages/settings/wallets/index.js b/pages/settings/wallets/index.js index 54a50cb7..fa6487f4 100644 --- a/pages/settings/wallets/index.js +++ b/pages/settings/wallets/index.js @@ -3,65 +3,69 @@ import Layout from '@/components/layout' import styles from '@/styles/wallet.module.css' import Link from 'next/link' import { useWallets } from '@/wallets/index' -import { useState } from 'react' +import { useCallback, useState } from 'react' import { useIsClient } from '@/components/use-client' import WalletCard from '@/components/wallet-card' +import { useToast } from '@/components/toast' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) -async function reorder (wallets, sourceIndex, targetIndex) { - const newOrder = [...wallets] - - const [source] = newOrder.splice(sourceIndex, 1) - const newTargetIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex - const append = sourceIndex < targetIndex - - newOrder.splice(newTargetIndex + (append ? 1 : 0), 0, source) - - await Promise.all( - newOrder.map((w, i) => - w.setPriority(i).catch(console.error) - ) - ) -} - export default function Wallet ({ ssrData }) { - const { wallets } = useWallets() - + const { wallets, setPriorities, reloadLocalWallets } = useWallets() + const toast = useToast() const isClient = useIsClient() const [sourceIndex, setSourceIndex] = useState(null) const [targetIndex, setTargetIndex] = useState(null) - const onDragStart = (i) => (e) => { + const reorder = useCallback(async (sourceIndex, targetIndex) => { + const newOrder = [...wallets.filter(w => w.config?.enabled)] + const [source] = newOrder.splice(sourceIndex, 1) + const newTargetIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex + + const priorities = newOrder.slice(0, newTargetIndex) + .concat(source) + .concat(newOrder.slice(newTargetIndex)) + .map((w, i) => ({ wallet: w, priority: i })) + + await setPriorities(priorities) + reloadLocalWallets() + }, [setPriorities, reloadLocalWallets, wallets]) + + const onDragStart = useCallback((i) => (e) => { // e.dataTransfer.dropEffect = 'move' // We can only use the DataTransfer API inside the drop event // see https://html.spec.whatwg.org/multipage/dnd.html#security-risks-in-the-drag-and-drop-model // e.dataTransfer.setData('text/plain', name) // That's why we use React state instead setSourceIndex(i) - } + }, [setSourceIndex]) - const onDragEnter = (i) => (e) => { + const onDragEnter = useCallback((i) => (e) => { setTargetIndex(i) - } + }, [setTargetIndex]) - const onDragEnd = async (e) => { + const onReorderError = useCallback((err) => { + console.error(err) + toast.danger('failed to reorder wallets') + }, [toast]) + + const onDragEnd = useCallback((e) => { setSourceIndex(null) setTargetIndex(null) if (sourceIndex === targetIndex) return - await reorder(wallets, sourceIndex, targetIndex) - } + reorder(sourceIndex, targetIndex).catch(onReorderError) + }, [sourceIndex, targetIndex, reorder, onReorderError]) - const onTouchStart = (i) => async (e) => { + const onTouchStart = useCallback((i) => (e) => { if (sourceIndex !== null) { - await reorder(wallets, sourceIndex, i) + reorder(sourceIndex, i).catch(onReorderError) setSourceIndex(null) } else { setSourceIndex(i) } - } + }, [sourceIndex, reorder, onReorderError]) return ( diff --git a/wallets/index.js b/wallets/index.js index d5b65434..44b482ad 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -1,9 +1,9 @@ import { useMe } from '@/components/me' -import { WALLETS } from '@/fragments/wallet' +import { SET_WALLET_PRIORITY, WALLETS } from '@/fragments/wallet' import { SSR } from '@/lib/constants' -import { useQuery } from '@apollo/client' +import { useMutation, useQuery } from '@apollo/client' import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' -import { getStorageKey, getWalletByType, Status, walletPrioritySort, canSend } from './common' +import { getStorageKey, getWalletByType, Status, walletPrioritySort, canSend, isConfigured } from './common' import useVault from '@/components/vault/use-vault' import { useWalletLogger } from '@/components/wallet-logger' import { bolt11Tags } from '@/lib/bolt11' @@ -21,7 +21,8 @@ function useLocalWallets () { // form wallets into a list of { config, def } const wallets = walletDefs.map(w => { try { - const config = window.localStorage.getItem(getStorageKey(w.name, me?.id)) + const storageKey = getStorageKey(w.name, me?.id) + const config = window.localStorage.getItem(storageKey) return { def: w, config: JSON.parse(config) } } catch (e) { return null @@ -43,10 +44,12 @@ export function WalletsProvider ({ children }) { const { decrypt } = useVault() const { me } = useMe() const { wallets: localWallets, reloadLocalWallets } = useLocalWallets() + const [setWalletPriority] = useMutation(SET_WALLET_PRIORITY) const { data, refetch } = useQuery(WALLETS, SSR ? {} : { nextFetchPolicy: 'cache-and-network' }) + // refetch wallets when the vault key hash changes or wallets are updated useEffect(() => { if (me?.privates?.walletsUpdatedAt) { refetch() @@ -77,9 +80,26 @@ export function WalletsProvider ({ children }) { .map(w => ({ ...w, status: w.config?.enabled ? Status.Enabled : Status.Disabled })) }, [data?.wallets, localWallets]) + const setPriorities = useCallback(async (priorities) => { + for (const { wallet, priority } of priorities) { + if (!isConfigured(wallet)) { + throw new Error(`cannot set priority for unconfigured wallet: ${wallet.def.name}`) + } + + if (wallet.config?.id) { + await setWalletPriority({ variables: { id: wallet.config.id, priority } }) + } else { + const storageKey = getStorageKey(wallet.def.name, me?.id) + const config = window.localStorage.getItem(storageKey) + const newConfig = { ...JSON.parse(config), priority } + window.localStorage.setItem(storageKey, JSON.stringify(newConfig)) + } + } + }, [setWalletPriority, me?.id]) + // provides priority sorted wallets to children return ( - + {children} ) From e96982c35369c5b57c4d86ced5d96a6672408b52 Mon Sep 17 00:00:00 2001 From: k00b Date: Fri, 25 Oct 2024 14:10:37 -0500 Subject: [PATCH 43/58] refactor wallet validation --- api/resolvers/invite.js | 4 +- api/resolvers/item.js | 18 +- api/resolvers/notifications.js | 4 +- api/resolvers/rewards.js | 4 +- api/resolvers/sub.js | 6 +- api/resolvers/user.js | 10 +- api/resolvers/wallet.js | 11 +- components/autowithdraw-shared.js | 14 +- lib/validate.js | 417 ++++------------------------- lib/yup.js | 167 ++++++++++++ pages/api/lnurlp/[username]/pay.js | 4 +- pages/settings/wallets/[wallet].js | 54 ++-- pages/settings/wallets/index.js | 5 +- wallets/blink/index.js | 13 +- wallets/cln/index.js | 29 +- wallets/common.js | 5 +- wallets/config.js | 38 ++- wallets/index.js | 32 ++- wallets/lightning-address/index.js | 10 +- wallets/lnbits/index.js | 34 ++- wallets/lnc/index.js | 24 +- wallets/lnd/index.js | 17 +- wallets/nwc/index.js | 9 +- wallets/phoenixd/index.js | 15 +- wallets/server.js | 3 +- wallets/validate.js | 91 +++++++ wallets/webln/index.js | 19 +- 27 files changed, 557 insertions(+), 500 deletions(-) create mode 100644 lib/yup.js create mode 100644 wallets/validate.js diff --git a/api/resolvers/invite.js b/api/resolvers/invite.js index 579a6cc1..347d7152 100644 --- a/api/resolvers/invite.js +++ b/api/resolvers/invite.js @@ -1,4 +1,4 @@ -import { inviteSchema, ssValidate } from '@/lib/validate' +import { inviteSchema, validateSchema } from '@/lib/validate' import { msatsToSats } from '@/lib/format' import assertApiKeyNotPermitted from './apiKey' import { GqlAuthenticationError } from '@/lib/error' @@ -35,7 +35,7 @@ export default { } assertApiKeyNotPermitted({ me }) - await ssValidate(inviteSchema, { gift, limit }) + await validateSchema(inviteSchema, { gift, limit }) return await models.invite.create({ data: { gift, limit, userId: me.id } diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 06ddec3c..ae7b6dc5 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -13,7 +13,7 @@ import { import { msatsToSats } from '@/lib/format' import { parse } from 'tldts' import uu from 'url-unshort' -import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '@/lib/validate' +import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, validateSchema } from '@/lib/validate' import { defaultCommentSort, isJob, deleteItemByAuthor } from '@/lib/item' import { datePivot, whenRange } from '@/lib/time' import { uploadIdsFromText } from './upload' @@ -844,7 +844,7 @@ export default { return await deleteItemByAuthor({ models, id, item: old }) }, upsertLink: async (parent, { id, ...item }, { me, models, lnd }) => { - await ssValidate(linkSchema, item, { models, me }) + await validateSchema(linkSchema, item, { models, me }) if (id) { return await updateItem(parent, { id, ...item }, { me, models, lnd }) @@ -853,7 +853,7 @@ export default { } }, upsertDiscussion: async (parent, { id, ...item }, { me, models, lnd }) => { - await ssValidate(discussionSchema, item, { models, me }) + await validateSchema(discussionSchema, item, { models, me }) if (id) { return await updateItem(parent, { id, ...item }, { me, models, lnd }) @@ -862,7 +862,7 @@ export default { } }, upsertBounty: async (parent, { id, ...item }, { me, models, lnd }) => { - await ssValidate(bountySchema, item, { models, me }) + await validateSchema(bountySchema, item, { models, me }) if (id) { return await updateItem(parent, { id, ...item }, { me, models, lnd }) @@ -879,7 +879,7 @@ export default { }) : 0 - await ssValidate(pollSchema, item, { models, me, numExistingChoices }) + await validateSchema(pollSchema, item, { models, me, numExistingChoices }) if (id) { return await updateItem(parent, { id, ...item }, { me, models, lnd }) @@ -894,7 +894,7 @@ export default { } item.location = item.location?.toLowerCase() === 'remote' ? undefined : item.location - await ssValidate(jobSchema, item, { models }) + await validateSchema(jobSchema, item, { models }) if (item.logo !== undefined) { item.uploadId = item.logo delete item.logo @@ -907,7 +907,7 @@ export default { } }, upsertComment: async (parent, { id, ...item }, { me, models, lnd }) => { - await ssValidate(commentSchema, item) + await validateSchema(commentSchema, item) if (id) { return await updateItem(parent, { id, ...item }, { me, models, lnd }) @@ -937,7 +937,7 @@ export default { }, act: async (parent, { id, sats, act = 'TIP' }, { me, models, lnd, headers }) => { assertApiKeyNotPermitted({ me }) - await ssValidate(actSchema, { sats, act }) + await validateSchema(actSchema, { sats, act }) await assertGofacYourself({ models, headers }) const [item] = await models.$queryRawUnsafe(` @@ -1369,7 +1369,7 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, .. } // in case they lied about their existing boost - await ssValidate(advSchema, { boost: item.boost }, { models, me, existingBoost: old.boost }) + await validateSchema(advSchema, { boost: item.boost }, { models, me, existingBoost: old.boost }) const user = await models.user.findUnique({ where: { id: meId } }) diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 20b6ceac..a934f64f 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -1,7 +1,7 @@ import { decodeCursor, LIMIT, nextNoteCursorEncoded } from '@/lib/cursor' import { getItem, filterClause, whereClause, muteClause, activeOrMine } from './item' import { getInvoice, getWithdrawl } from './wallet' -import { pushSubscriptionSchema, ssValidate } from '@/lib/validate' +import { pushSubscriptionSchema, validateSchema } from '@/lib/validate' import { replyToSubscription } from '@/lib/webPush' import { getSub } from './sub' import { GqlAuthenticationError, GqlInputError } from '@/lib/error' @@ -375,7 +375,7 @@ export default { throw new GqlAuthenticationError() } - await ssValidate(pushSubscriptionSchema, { endpoint, p256dh, auth }) + await validateSchema(pushSubscriptionSchema, { endpoint, p256dh, auth }) let dbPushSubscription if (oldEndpoint) { diff --git a/api/resolvers/rewards.js b/api/resolvers/rewards.js index ce476f35..3dcfd2c2 100644 --- a/api/resolvers/rewards.js +++ b/api/resolvers/rewards.js @@ -1,4 +1,4 @@ -import { amountSchema, ssValidate } from '@/lib/validate' +import { amountSchema, validateSchema } from '@/lib/validate' import { getAd, getItem } from './item' import { topUsers } from './user' import performPaidAction from '../paidAction' @@ -171,7 +171,7 @@ export default { }, Mutation: { donateToRewards: async (parent, { sats }, { me, models, lnd }) => { - await ssValidate(amountSchema, { amount: sats }) + await validateSchema(amountSchema, { amount: sats }) return await performPaidAction('DONATE', { sats }, { me, models, lnd }) } diff --git a/api/resolvers/sub.js b/api/resolvers/sub.js index 09279869..320670b6 100644 --- a/api/resolvers/sub.js +++ b/api/resolvers/sub.js @@ -1,5 +1,5 @@ import { whenRange } from '@/lib/time' -import { ssValidate, territorySchema } from '@/lib/validate' +import { validateSchema, territorySchema } from '@/lib/validate' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { viewGroup } from './growth' import { notifyTerritoryTransfer } from '@/lib/webPush' @@ -157,7 +157,7 @@ export default { throw new GqlAuthenticationError() } - await ssValidate(territorySchema, data, { models, me, sub: { name: data.oldName } }) + await validateSchema(territorySchema, data, { models, me, sub: { name: data.oldName } }) if (data.oldName) { return await updateSub(parent, data, { me, models, lnd }) @@ -260,7 +260,7 @@ export default { const { name } = data - await ssValidate(territorySchema, data, { models, me }) + await validateSchema(territorySchema, data, { models, me }) const oldSub = await models.sub.findUnique({ where: { name } }) if (!oldSub) { diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 86e73e04..cd518e70 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -2,7 +2,7 @@ import { readFile } from 'fs/promises' import { join, resolve } from 'path' import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor' import { msatsToSats } from '@/lib/format' -import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '@/lib/validate' +import { bioSchema, emailSchema, settingsSchema, validateSchema, userSchema } from '@/lib/validate' import { getItem, updateItem, filterClause, createItem, whereClause, muteClause, activeOrMine } from './item' import { USER_ID, RESERVED_MAX_USER_ID, SN_NO_REWARDS_IDS, INVOICE_ACTION_NOTIFICATION_TYPES } from '@/lib/constants' import { viewGroup } from './growth' @@ -632,7 +632,7 @@ export default { throw new GqlAuthenticationError() } - await ssValidate(userSchema, data, { models }) + await validateSchema(userSchema, data, { models }) try { await models.user.update({ where: { id: me.id }, data }) @@ -649,7 +649,7 @@ export default { throw new GqlAuthenticationError() } - await ssValidate(settingsSchema, { nostrRelays, ...data }) + await validateSchema(settingsSchema, { nostrRelays, ...data }) if (nostrRelays?.length) { const connectOrCreate = [] @@ -696,7 +696,7 @@ export default { throw new GqlAuthenticationError() } - await ssValidate(bioSchema, { text }) + await validateSchema(bioSchema, { text }) const user = await models.user.findUnique({ where: { id: me.id } }) @@ -770,7 +770,7 @@ export default { } assertApiKeyNotPermitted({ me }) - await ssValidate(emailSchema, { email }) + await validateSchema(emailSchema, { email }) try { await models.user.update({ diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 05ac369e..6711b20b 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -12,7 +12,7 @@ import { ANON_BALANCE_LIMIT_MSATS, ANON_INV_PENDING_LIMIT, USER_ID, BALANCE_LIMIT_MSATS, INVOICE_RETENTION_DAYS, INV_PENDING_LIMIT, USER_IDS_BALANCE_NO_LIMIT, LND_PATHFINDING_TIMEOUT_MS } from '@/lib/constants' -import { amountSchema, ssValidate, withdrawlSchema, lnAddrSchema, walletValidate } from '@/lib/validate' +import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate' import { datePivot } from '@/lib/time' import assertGofacYourself from './ofac' import assertApiKeyNotPermitted from './apiKey' @@ -23,6 +23,7 @@ import { generateResolverName, generateTypeDefName } from '@/wallets/graphql' import { lnAddrOptions } from '@/lib/lnurl' import { GqlAuthenticationError, GqlAuthorizationError, GqlInputError } from '@/lib/error' import { getNodeSockets, getOurPubkey } from '../lnd' +import validateWallet from '@/wallets/validate' function injectResolvers (resolvers) { console.group('injected GraphQL resolvers:') @@ -32,7 +33,7 @@ function injectResolvers (resolvers) { 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) // TODO: our validation should be improved - const validData = await walletValidate(walletDef, { ...data, ...settings, vaultEntries }) + const validData = await validateWallet(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] }) @@ -437,7 +438,7 @@ const resolvers = { }, Mutation: { createInvoice: async (parent, { amount, hodlInvoice = false, expireSecs = 3600 }, { me, models, lnd, headers }) => { - await ssValidate(amountSchema, { amount }) + await validateSchema(amountSchema, { amount }) await assertGofacYourself({ models, headers }) let expirePivot = { seconds: expireSecs } @@ -783,7 +784,7 @@ async function upsertWallet ( export async function createWithdrawal (parent, { invoice, maxFee }, { me, models, lnd, headers, walletId = null }) { assertApiKeyNotPermitted({ me }) - await ssValidate(withdrawlSchema, { invoice, maxFee }) + await validateSchema(withdrawlSchema, { invoice, maxFee }) await assertGofacYourself({ models, headers }) // remove 'lightning:' prefix if present @@ -867,7 +868,7 @@ export async function fetchLnAddrInvoice ( me, models, lnd, autoWithdraw = false }) { const options = await lnAddrOptions(addr) - await ssValidate(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options) + await validateSchema(lnAddrSchema, { addr, amount, maxFee, comment, ...payer }, options) if (payer) { payer = { diff --git a/components/autowithdraw-shared.js b/components/autowithdraw-shared.js index 47f01bb8..6bdc4045 100644 --- a/components/autowithdraw-shared.js +++ b/components/autowithdraw-shared.js @@ -1,11 +1,9 @@ import { InputGroup } from 'react-bootstrap' -import { Checkbox, Input } from './form' +import { Input } from './form' import { useMe } from './me' import { useEffect, useState } from 'react' import { isNumber } from '@/lib/validate' -import { useIsClient } from './use-client' import Link from 'next/link' -import { isConfigured } from '@/wallets/common' function autoWithdrawThreshold ({ me }) { return isNumber(me?.privates?.autoWithdrawThreshold) ? me?.privates?.autoWithdrawThreshold : 10000 @@ -19,7 +17,7 @@ export function autowithdrawInitial ({ me }) { } } -export function AutowithdrawSettings ({ wallet }) { +export function AutowithdrawSettings () { const { me } = useMe() const threshold = autoWithdrawThreshold({ me }) @@ -29,16 +27,8 @@ export function AutowithdrawSettings ({ wallet }) { setSendThreshold(Math.max(Math.floor(threshold / 10), 1)) }, [autoWithdrawThreshold]) - const isClient = useIsClient() - return ( <> -

desired balance

diff --git a/lib/validate.js b/lib/validate.js index 96753c94..5e9b30c9 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -1,4 +1,4 @@ -import { string, ValidationError, number, object, array, addMethod, boolean, date } from 'yup' +import { string, ValidationError, number, object, array, boolean, date } from './yup' import { BOOST_MIN, MAX_POLL_CHOICE_LENGTH, MAX_TITLE_LENGTH, MAX_POLL_NUM_CHOICES, MIN_POLL_NUM_CHOICES, MAX_FORWARDS, BOOST_MULT, MAX_TERRITORY_DESC_LENGTH, POST_TYPES, @@ -6,19 +6,16 @@ import { } from './constants' import { SUPPORTED_CURRENCIES } from './currency' import { NOSTR_MAX_RELAY_NUM, NOSTR_PUBKEY_BECH32, NOSTR_PUBKEY_HEX } from './nostr' -import { msatsToSats, numWithUnits, abbrNum, ensureB64, B64_URL_REGEX, HEX_REGEX } from './format' +import { msatsToSats, numWithUnits, abbrNum } from './format' import * as usersFragments from '@/fragments/users' import * as subsFragments from '@/fragments/subs' -import { isInvoicableMacaroon, isInvoiceMacaroon } from './macaroon' -import { TOR_REGEXP, parseNwcUrl } from './url' import { datePivot } from './time' -import { decodeRune } from '@/lib/cln' import bip39Words from './bip39-words' const { SUB } = subsFragments const { NAME_QUERY } = usersFragments -export async function ssValidate (schema, data, args) { +export async function validateSchema (schema, data, args) { try { if (typeof schema === 'function') { return await schema(args).validate(data) @@ -33,159 +30,6 @@ export async function ssValidate (schema, data, args) { } } -export async function formikValidate (validate, data) { - const result = await validate(data) - if (Object.keys(result).length > 0) { - const [key, message] = Object.entries(result)[0] - throw new Error(`${key}: ${message}`) - } - return result -} - -export async function walletValidate (walletDef, data) { - if (typeof walletDef.fieldValidation === 'function') { - return await formikValidate(walletDef.fieldValidation, data) - } else { - return await ssValidate(walletDef.fieldValidation, data) - } -} - -addMethod(string, 'or', function (schemas, msg) { - return this.test({ - name: 'or', - message: msg, - test: value => { - if (Array.isArray(schemas) && schemas.length > 1) { - const resee = schemas.map(schema => schema.isValidSync(value)) - return resee.some(res => res) - } else { - throw new TypeError('Schemas is not correct array schema') - } - }, - exclusive: false - }) -}) - -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 => { - if (typeof value === 'undefined') return true - try { - const url = new URL(value) - return url.protocol === 'ws:' || url.protocol === 'wss:' - } 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', - message: 'https required', - test: (url) => { - try { - return new URL(url).protocol === 'https:' - } catch { - return false - } - } - }) -}) - -addMethod(string, 'wss', function (msg) { - return this.test({ - name: 'wss', - message: msg || 'wss required', - test: (url) => { - try { - return new URL(url).protocol === 'wss:' - } catch { - return false - } - } - }) -}) - -addMethod(string, 'hex', function (msg) { - return this.test({ - name: 'hex', - message: msg || 'invalid hex encoding', - test: (value) => !value || HEX_REGEX.test(value) - }) -}) - -addMethod(string, 'nwcUrl', function () { - return this.test({ - test: async (nwcUrl, context) => { - if (!nwcUrl) return true - - // run validation in sequence to control order of errors - // inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180 - try { - await string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validate(nwcUrl) - let relayUrl, walletPubkey, secret - try { - ({ relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl)) - } catch { - // invalid URL error. handle as if pubkey validation failed to not confuse user. - throw new Error('pubkey must be 64 hex chars') - } - await string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validate(walletPubkey) - await string().required('relay url required').trim().wss('relay must use wss://').validate(relayUrl) - await string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validate(secret) - } catch (err) { - return context.createError({ message: err.message }) - } - return true - } - }) -}) - const titleValidator = string().required('required').trim().max( MAX_TITLE_LENGTH, ({ max, value }) => `-${Math.abs(max - value.length)} characters remaining` @@ -203,32 +47,12 @@ const nameValidator = string() const intValidator = number().typeError('must be a number').integer('must be whole') const floatValidator = number().typeError('must be a number') -const lightningAddressValidator = process.env.NODE_ENV === 'development' +export const lightningAddressValidator = process.env.NODE_ENV === 'development' ? string().or( [string().matches(/^[\w_]+@localhost:\d+$/), string().matches(/^[\w_]+@app:\d+$/), string().email()], 'address is no good') : string().email('address is no good') -const hexOrBase64Validator = string().test({ - name: 'hex-or-base64', - message: 'invalid encoding', - test: (val) => { - if (typeof val === 'undefined') return true - try { - ensureB64(val) - return true - } catch { - return false - } - } -}).transform(val => { - try { - return ensureB64(val) - } catch { - return val - } -}) - async function usernameExists (name, { client, models }) { if (!client && !models) { throw new Error('cannot check for user') @@ -363,56 +187,59 @@ export function advSchema (args) { }) } -export const autowithdrawSchemaMembers = { - enabled: 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'), - autoWithdrawMaxFeeTotal: intValidator.required('required').min(0, 'must be at least 0').max(1_000, 'must not exceed 1000') -} - -export const lnAddrAutowithdrawSchema = object({ - address: lightningAddressValidator.required('required').test({ - name: 'address', - test: addr => !addr.endsWith('@stacker.news'), - message: 'automated withdrawals must be external' - }), - ...autowithdrawSchemaMembers +export const autowithdrawSchemaMembers = object({ + 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))}`).transform(Number), + autoWithdrawMaxFeePercent: floatValidator.required('required').min(0, 'must be at least 0').max(50, 'must not exceed 50').transform(Number), + autoWithdrawMaxFeeTotal: intValidator.required('required').min(0, 'must be at least 0').max(1_000, 'must not exceed 1000').transform(Number) }) -export const LNDAutowithdrawSchema = object({ - socket: string().socket().required('required'), - macaroon: hexOrBase64Validator.required('required').test({ - name: 'macaroon', - test: v => isInvoiceMacaroon(v) || isInvoicableMacaroon(v), - message: 'not an invoice macaroon or an invoicable macaroon' - }), - cert: hexOrBase64Validator, - ...autowithdrawSchemaMembers -}) +export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) => + object({ + addr: lightningAddressValidator.required('required'), + amount: (() => { + const schema = intValidator.required('required').positive('must be positive').min( + min || 1, `must be at least ${min || 1}`) + return max ? schema.max(max, `must be at most ${max}`) : schema + })(), + maxFee: intValidator.required('required').min(0, 'must be at least 0'), + comment: commentAllowed + ? string().max(commentAllowed, `must be less than ${commentAllowed}`) + : string() + }).concat(object().shape(Object.keys(payerData || {}).reduce((accum, key) => { + const entry = payerData[key] + if (key === 'email') { + accum[key] = string().email() + } else if (key === 'identifier') { + accum[key] = boolean() + } else { + accum[key] = string() + } + if (entry?.mandatory) { + accum[key] = accum[key].required() + } + return accum + }, {}))) -export const CLNAutowithdrawSchema = object({ - socket: string().socket().required('required'), - rune: string().matches(B64_URL_REGEX, { message: 'invalid rune' }).required('required') - .test({ - name: 'rune', - test: (v, context) => { - const decoded = decodeRune(v) - if (!decoded) return context.createError({ message: 'invalid rune' }) - if (decoded.restrictions.length === 0) { - return context.createError({ message: 'rune must be restricted to method=invoice' }) - } - if (decoded.restrictions.length !== 1 || decoded.restrictions[0].alternatives.length !== 1) { - return context.createError({ message: 'rune must be restricted to method=invoice only' }) - } - if (decoded.restrictions[0].alternatives[0] !== 'method=invoice') { - return context.createError({ message: 'rune must be restricted to method=invoice only' }) - } - return true - } +export const phoenixdSchema = object().shape({ + url: string().url().required('required').trim(), + primaryPassword: string().length(64).hex() + .when(['secondaryPassword'], ([secondary], schema) => { + if (!secondary) return schema.required('required if secondary password not set') + return schema.test({ + test: primary => secondary !== primary, + message: 'primary password cannot be the same as secondary password' + }) + }), + secondaryPassword: string().length(64).hex() + .when(['primaryPassword'], ([primary], schema) => { + if (!primary) return schema.required('required if primary password not set') + return schema.test({ + test: secondary => primary !== secondary, + message: 'secondary password cannot be the same as primary password' + }) }), - cert: hexOrBase64Validator, ...autowithdrawSchemaMembers -}) +}, ['primaryPassword', 'secondaryPassword']) export function bountySchema (args) { return object({ @@ -663,146 +490,6 @@ export const withdrawlSchema = object({ maxFee: intValidator.required('required').min(0, 'must be at least 0') }) -export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) => - object({ - addr: lightningAddressValidator.required('required'), - amount: (() => { - const schema = intValidator.required('required').positive('must be positive').min( - min || 1, `must be at least ${min || 1}`) - return max ? schema.max(max, `must be at most ${max}`) : schema - })(), - maxFee: intValidator.required('required').min(0, 'must be at least 0'), - comment: commentAllowed - ? string().max(commentAllowed, `must be less than ${commentAllowed}`) - : string() - }).concat(object().shape(Object.keys(payerData || {}).reduce((accum, key) => { - const entry = payerData[key] - if (key === 'email') { - accum[key] = string().email() - } else if (key === 'identifier') { - accum[key] = boolean() - } else { - accum[key] = string() - } - if (entry?.mandatory) { - accum[key] = accum[key].required() - } - return accum - }, {}))) - -export const lnbitsSchema = object().shape({ - url: process.env.NODE_ENV === 'development' - ? string() - .or([string().matches(/^(http:\/\/)?localhost:\d+$/), string().url()], 'invalid url') - .required('required').trim() - : string().url().required('required').trim() - .test(async (url, context) => { - if (TOR_REGEXP.test(url)) { - // allow HTTP and HTTPS over Tor - if (!/^https?:\/\//.test(url)) { - return context.createError({ message: 'http or https required' }) - } - return true - } - try { - // force HTTPS over clearnet - await string().https().validate(url) - } catch (err) { - return context.createError({ message: err.message }) - } - return true - }), - adminKey: string().length(32).hex() - .when(['invoiceKey'], ([invoiceKey], schema) => { - if (!invoiceKey) return schema.required('required if invoice key not set') - return schema.test({ - test: adminKey => adminKey !== invoiceKey, - message: 'admin key cannot be the same as invoice key' - }) - }), - invoiceKey: string().length(32).hex() - .when(['adminKey'], ([adminKey], schema) => { - if (!adminKey) return schema.required('required if admin key not set') - return schema.test({ - test: invoiceKey => adminKey !== invoiceKey, - message: 'invoice key cannot be the same as admin key' - }) - }), - ...autowithdrawSchemaMembers - // need to set order to avoid cyclic dependencies in Yup schema - // see https://github.com/jquense/yup/issues/176#issuecomment-367352042 -}, ['adminKey', 'invoiceKey']) - -export const nwcSchema = object().shape({ - nwcUrl: string().nwcUrl().when(['nwcUrlRecv'], ([nwcUrlRecv], schema) => { - if (!nwcUrlRecv) return schema.required('required if connection for receiving not set') - return schema.test({ - test: nwcUrl => nwcUrl !== nwcUrlRecv, - message: 'connection for sending cannot be the same as for receiving' - }) - }), - nwcUrlRecv: string().nwcUrl().when(['nwcUrl'], ([nwcUrl], schema) => { - if (!nwcUrl) return schema.required('required if connection for sending not set') - return schema.test({ - test: nwcUrlRecv => nwcUrlRecv !== nwcUrl, - message: 'connection for receiving cannot be the same as for sending' - }) - }), - ...autowithdrawSchemaMembers -}, ['nwcUrl', 'nwcUrlRecv']) - -export const blinkSchema = object({ - apiKey: string() - .required('required') - .matches(/^blink_[A-Za-z0-9]+$/, { message: 'must match pattern blink_A-Za-z0-9' }), - currency: string() - .transform(value => value ? value.toUpperCase() : 'BTC') - .oneOf(['USD', 'BTC'], 'must be BTC or USD') -}) - -export const lncSchema = object({ - pairingPhrase: string() - .test(async (value, context) => { - const words = value ? value.trim().split(/[\s]+/) : [] - for (const w of words) { - try { - await string().oneOf(bip39Words).validate(w) - } catch { - return context.createError({ message: `'${w}' is not a valid pairing phrase word` }) - } - } - if (words.length < 2) { - return context.createError({ message: 'needs at least two words' }) - } - if (words.length > 10) { - return context.createError({ message: 'max 10 words' }) - } - return true - }) - .required('required') -}) - -export const phoenixdSchema = object().shape({ - url: string().url().required('required').trim(), - primaryPassword: string().length(64).hex() - .when(['secondaryPassword'], ([secondary], schema) => { - if (!secondary) return schema.required('required if secondary password not set') - return schema.test({ - test: primary => secondary !== primary, - message: 'primary password cannot be the same as secondary password' - }) - }), - secondaryPassword: string().length(64).hex() - .when(['primaryPassword'], ([primary], schema) => { - if (!primary) return schema.required('required if primary password not set') - return schema.test({ - test: secondary => primary !== secondary, - message: 'secondary password cannot be the same as primary password' - }) - }), - ...autowithdrawSchemaMembers -}, ['primaryPassword', 'secondaryPassword']) - export const bioSchema = object({ text: string().required('required').trim() }) diff --git a/lib/yup.js b/lib/yup.js new file mode 100644 index 00000000..6102eae4 --- /dev/null +++ b/lib/yup.js @@ -0,0 +1,167 @@ +import { addMethod, string, mixed } from 'yup' +import { parseNwcUrl } from './url' +import { NOSTR_PUBKEY_HEX } from './nostr' +import { ensureB64, HEX_REGEX } from './format' + +function orFunc (schemas, msg) { + return this.test({ + name: 'or', + message: msg, + test: value => { + if (Array.isArray(schemas) && schemas.length > 1) { + const resee = schemas.map(schema => schema.isValidSync(value)) + return resee.some(res => res) + } else { + throw new TypeError('Schemas is not correct array schema') + } + }, + exclusive: false + }) +} + +addMethod(mixed, 'or', orFunc) +addMethod(string, 'or', orFunc) + +addMethod(string, 'hexOrBase64', function (schemas, msg = 'invalid hex or base64 encoding') { + return this.test({ + name: 'hex-or-base64', + message: 'invalid encoding', + test: (val) => { + if (typeof val === 'undefined') return true + try { + ensureB64(val) + return true + } catch { + return false + } + } + }).transform(val => { + try { + return ensureB64(val) + } catch { + return val + } + }) +}) + +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 => { + if (typeof value === 'undefined') return true + try { + const url = new URL(value) + return url.protocol === 'ws:' || url.protocol === 'wss:' + } 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', + message: 'https required', + test: (url) => { + try { + return new URL(url).protocol === 'https:' + } catch { + return false + } + } + }) +}) + +addMethod(string, 'wss', function (msg) { + return this.test({ + name: 'wss', + message: msg || 'wss required', + test: (url) => { + try { + return new URL(url).protocol === 'wss:' + } catch { + return false + } + } + }) +}) + +addMethod(string, 'hex', function (msg) { + return this.test({ + name: 'hex', + message: msg || 'invalid hex encoding', + test: (value) => !value || HEX_REGEX.test(value) + }) +}) + +addMethod(string, 'nwcUrl', function () { + return this.test({ + test: async (nwcUrl, context) => { + if (!nwcUrl) return true + + // run validation in sequence to control order of errors + // inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180 + try { + await string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validate(nwcUrl) + let relayUrl, walletPubkey, secret + try { + ({ relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl)) + } catch { + // invalid URL error. handle as if pubkey validation failed to not confuse user. + throw new Error('pubkey must be 64 hex chars') + } + await string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validate(walletPubkey) + await string().required('relay url required').trim().wss('relay must use wss://').validate(relayUrl) + await string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validate(secret) + } catch (err) { + return context.createError({ message: err.message }) + } + return true + } + }) +}) + +export * from 'yup' diff --git a/pages/api/lnurlp/[username]/pay.js b/pages/api/lnurlp/[username]/pay.js index b153a2ee..6cfc9b44 100644 --- a/pages/api/lnurlp/[username]/pay.js +++ b/pages/api/lnurlp/[username]/pay.js @@ -7,7 +7,7 @@ import { schnorr } from '@noble/curves/secp256k1' import { createHash } from 'crypto' import { datePivot } from '@/lib/time' import { BALANCE_LIMIT_MSATS, INV_PENDING_LIMIT, LNURLP_COMMENT_MAX_LENGTH, USER_IDS_BALANCE_NO_LIMIT } from '@/lib/constants' -import { ssValidate, lud18PayerDataSchema } from '@/lib/validate' +import { validateSchema, lud18PayerDataSchema } from '@/lib/validate' import assertGofacYourself from '@/api/resolvers/ofac' export default async ({ query: { username, amount, nostr, comment, payerdata: payerData }, headers }, res) => { @@ -59,7 +59,7 @@ export default async ({ query: { username, amount, nostr, comment, payerdata: pa } try { - await ssValidate(lud18PayerDataSchema, parsedPayerData) + await validateSchema(lud18PayerDataSchema, parsedPayerData) } catch (err) { console.error('error validating payer data', err) return res.status(400).json({ status: 'ERROR', reason: err.toString() }) diff --git a/pages/settings/wallets/[wallet].js b/pages/settings/wallets/[wallet].js index 95de9f61..f563b13c 100644 --- a/pages/settings/wallets/[wallet].js +++ b/pages/settings/wallets/[wallet].js @@ -9,12 +9,15 @@ import { useWallet } from '@/wallets/index' import Info from '@/components/info' import Text from '@/components/text' import { autowithdrawInitial, AutowithdrawSettings } from '@/components/autowithdraw-shared' -import { canSend, isConfigured } from '@/wallets/common' +import { canReceive, canSend, isConfigured } from '@/wallets/common' import { SSR } from '@/lib/constants' import WalletButtonBar from '@/components/wallet-buttonbar' import { useWalletConfigurator } from '@/wallets/config' -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' import { useMe } from '@/components/me' +import validateWallet from '@/wallets/validate' +import { ValidationError } from 'yup' +import { useFormikContext } from 'formik' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) @@ -47,10 +50,19 @@ export default function WalletSettings () { } }, [wallet, me]) - // check if wallet uses the form-level validation built into Formik or a Yup schema - const validateProps = typeof wallet?.fieldValidation === 'function' - ? { validate: wallet?.fieldValidation } - : { schema: wallet?.fieldValidation } + const validate = useCallback(async (data) => { + try { + await validateWallet(wallet.def, data, { abortEarly: false, topLevel: false }) + } catch (error) { + if (error instanceof ValidationError) { + return error.inner.reduce((acc, error) => { + acc[error.path] = error.message + return acc + }, {}) + } + throw error + } + }, [wallet.def]) return ( @@ -60,7 +72,7 @@ export default function WalletSettings () { { try { const newConfig = !isConfigured(wallet) @@ -81,18 +93,15 @@ export default function WalletSettings () { }} > {wallet && } - {wallet?.def.clientOnly - ? ( - - - - ) - : } + + + + { try { @@ -114,9 +123,14 @@ export default function WalletSettings () { ) } +function ReceiveSettings ({ walletDef }) { + const { values } = useFormikContext() + return canReceive({ def: walletDef, config: values }) && +} + function WalletFields ({ wallet }) { return wallet.def.fields - .map(({ name, label = '', type, help, optional, editable, clientOnly, serverOnly, ...props }, i) => { + .map(({ name, label = '', type, help, optional, editable, requiredWithout, validate, clientOnly, serverOnly, ...props }, i) => { const rawProps = { ...props, name, diff --git a/pages/settings/wallets/index.js b/pages/settings/wallets/index.js index fa6487f4..8549eee0 100644 --- a/pages/settings/wallets/index.js +++ b/pages/settings/wallets/index.js @@ -11,7 +11,7 @@ import { useToast } from '@/components/toast' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) export default function Wallet ({ ssrData }) { - const { wallets, setPriorities, reloadLocalWallets } = useWallets() + const { wallets, setPriorities } = useWallets() const toast = useToast() const isClient = useIsClient() const [sourceIndex, setSourceIndex] = useState(null) @@ -28,8 +28,7 @@ export default function Wallet ({ ssrData }) { .map((w, i) => ({ wallet: w, priority: i })) await setPriorities(priorities) - reloadLocalWallets() - }, [setPriorities, reloadLocalWallets, wallets]) + }, [setPriorities, wallets]) const onDragStart = useCallback((i) => (e) => { // e.dataTransfer.dropEffect = 'move' diff --git a/wallets/blink/index.js b/wallets/blink/index.js index 10c97cfd..b10d8205 100644 --- a/wallets/blink/index.js +++ b/wallets/blink/index.js @@ -1,4 +1,5 @@ import { blinkSchema } from '@/lib/validate' +import { string } from '@/lib/yup' export const galoyBlinkUrl = 'https://api.blink.sv/graphql' export const galoyBlinkDashboardUrl = 'https://dashboard.blink.sv/' @@ -7,7 +8,6 @@ export const name = 'blink' export const walletType = 'BLINK' export const walletField = 'walletBlink' export const fieldValidation = blinkSchema -export const clientOnly = true export const fields = [ { @@ -15,7 +15,10 @@ export const fields = [ label: 'api key', type: 'password', help: `you can get an API key from [Blink Dashboard](${galoyBlinkDashboardUrl})`, - placeholder: 'blink_...' + placeholder: 'blink_...', + clientOnly: true, + validate: string() + .matches(/^blink_[A-Za-z0-9]+$/, { message: 'must match pattern blink_A-Za-z0-9' }) }, { name: 'currency', @@ -25,7 +28,11 @@ export const fields = [ placeholder: 'BTC', optional: true, clear: true, - autoComplete: 'off' + autoComplete: 'off', + clientOnly: true, + validate: string() + .transform(value => value ? value.toUpperCase() : 'BTC') + .oneOf(['USD', 'BTC'], 'must be BTC or USD') } ] diff --git a/wallets/cln/index.js b/wallets/cln/index.js index 3ee2013a..16c21cb7 100644 --- a/wallets/cln/index.js +++ b/wallets/cln/index.js @@ -1,4 +1,7 @@ +import { decodeRune } from '@/lib/cln' +import { B64_URL_REGEX } from '@/lib/format' import { CLNAutowithdrawSchema } from '@/lib/validate' +import { string } from '@/lib/yup' export const name = 'cln' export const walletType = 'CLN' @@ -13,7 +16,8 @@ export const fields = [ placeholder: '55.5.555.55:3010', hint: 'tor or clearnet', clear: true, - serverOnly: true + serverOnly: true, + validate: string().socket() }, { name: 'rune', @@ -25,7 +29,25 @@ export const fields = [ placeholder: 'S34KtUW-6gqS_hD_9cc_PNhfF-NinZyBOCgr1aIrark9NCZtZXRob2Q9aW52b2ljZQ==', hint: 'must be restricted to method=invoice', clear: true, - serverOnly: true + serverOnly: true, + validate: string().matches(B64_URL_REGEX, { message: 'invalid rune' }) + .test({ + name: 'rune', + test: (v, context) => { + const decoded = decodeRune(v) + if (!decoded) return context.createError({ message: 'invalid rune' }) + if (decoded.restrictions.length === 0) { + return context.createError({ message: 'rune must be restricted to method=invoice' }) + } + if (decoded.restrictions.length !== 1 || decoded.restrictions[0].alternatives.length !== 1) { + return context.createError({ message: 'rune must be restricted to method=invoice only' }) + } + if (decoded.restrictions[0].alternatives[0] !== 'method=invoice') { + return context.createError({ message: 'rune must be restricted to method=invoice only' }) + } + return true + } + }) }, { name: 'cert', @@ -35,7 +57,8 @@ export const fields = [ optional: 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)', hint: 'hex or base64 encoded', clear: true, - serverOnly: true + serverOnly: true, + validate: string().hexOrBase64() } ] diff --git a/wallets/common.js b/wallets/common.js index 76d33aa9..b3cb7843 100644 --- a/wallets/common.js +++ b/wallets/common.js @@ -62,7 +62,8 @@ export function isClientField (f) { function checkFields ({ fields, config }) { // a wallet is configured if all of its required fields are set let val = fields.every(f => { - return f.optional ? true : !!config?.[f.name] + if (f.optional && !f.requiredWithout) return true + return !!config?.[f.name] }) // however, a wallet is not configured if all fields are optional and none are set @@ -93,5 +94,5 @@ export function canSend ({ def, config }) { } export function canReceive ({ def, config }) { - return !def.clientOnly && isReceiveConfigured({ def, config }) + return def.fields.some(f => f.serverOnly) && isReceiveConfigured({ def, config }) } diff --git a/wallets/config.js b/wallets/config.js index d4312ed9..2c827ff5 100644 --- a/wallets/config.js +++ b/wallets/config.js @@ -5,9 +5,9 @@ import { canReceive, canSend, getStorageKey } from './common' import { useMutation } from '@apollo/client' import { generateMutation } from './graphql' import { REMOVE_WALLET } from '@/fragments/wallet' -import { walletValidate } from '@/lib/validate' import { useWalletLogger } from '@/components/wallet-logger' import { useWallets } from '.' +import validateWallet from './validate' export function useWalletConfigurator (wallet) { const { me } = useMe() @@ -20,11 +20,14 @@ export function useWalletConfigurator (wallet) { const _saveToServer = useCallback(async (serverConfig, clientConfig, validateLightning) => { const { serverWithShared, settings, clientOnly } = siftConfig(wallet.def.fields, { ...serverConfig, ...clientConfig }) const vaultEntries = [] - if (clientOnly) { + if (clientOnly && isActive) { for (const [key, value] of Object.entries(clientOnly)) { - vaultEntries.push({ key, value: encrypt(value) }) + if (value) { + vaultEntries.push({ key, value: encrypt(value) }) + } } } + await upsertWallet({ variables: { ...serverWithShared, settings, validateLightning, vaultEntries } }) }, [encrypt, isActive, wallet.def.fields]) @@ -40,7 +43,7 @@ export function useWalletConfigurator (wallet) { let serverConfig = serverWithShared if (canSend({ def: wallet.def, config: clientConfig })) { - let transformedConfig = await walletValidate(wallet.def, clientWithShared) + let transformedConfig = await validateWallet(wallet.def, clientWithShared) if (transformedConfig) { clientConfig = Object.assign(clientConfig, transformedConfig) } @@ -51,7 +54,7 @@ export function useWalletConfigurator (wallet) { } } } else if (canReceive({ def: wallet.def, config: serverConfig })) { - const transformedConfig = await walletValidate(wallet.def, serverConfig) + const transformedConfig = await validateWallet(wallet.def, serverConfig) if (transformedConfig) { serverConfig = Object.assign(serverConfig, transformedConfig) } @@ -62,6 +65,15 @@ export function useWalletConfigurator (wallet) { return { clientConfig, serverConfig } }, [wallet]) + const _detachFromServer = useCallback(async () => { + await removeWallet({ variables: { id: wallet.config.id } }) + }, [wallet.config?.id]) + + const _detachFromLocal = useCallback(async () => { + window.localStorage.removeItem(getStorageKey(wallet.def.name, me?.id)) + reloadLocalWallets() + }, [me?.id, wallet.def.name, reloadLocalWallets]) + const save = useCallback(async (newConfig, validateLightning = true) => { const { clientConfig, serverConfig } = await _validate(newConfig, validateLightning) @@ -71,20 +83,18 @@ export function useWalletConfigurator (wallet) { } else { if (canSend({ def: wallet.def, config: clientConfig })) { await _saveToLocal(clientConfig) + } else { + // if it previously had a client config, remove it + await _detachFromLocal() } if (canReceive({ def: wallet.def, config: serverConfig })) { await _saveToServer(serverConfig, clientConfig, validateLightning) + } else { + // if it previously had a server config, remove it + await _detachFromServer() } } - }, [isActive, _saveToServer, _saveToLocal, _validate]) - - const _detachFromServer = useCallback(async () => { - await removeWallet({ variables: { id: wallet.config.id } }) - }, [wallet.config?.id]) - - const _detachFromLocal = useCallback(async () => { - window.localStorage.removeItem(getStorageKey(wallet.def.name, me?.id)) - }, [me?.id, wallet.def.name]) + }, [isActive, _saveToServer, _saveToLocal, _validate, _detachFromLocal, _detachFromServer]) const detach = useCallback(async () => { if (isActive) { diff --git a/wallets/index.js b/wallets/index.js index 44b482ad..99eea902 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -18,7 +18,7 @@ function useLocalWallets () { const [wallets, setWallets] = useState([]) const loadWallets = useCallback(() => { - // form wallets into a list of { config, def } + // form wallets from local storage into a list of { config, def } const wallets = walletDefs.map(w => { try { const storageKey = getStorageKey(w.name, me?.id) @@ -66,15 +66,28 @@ export function WalletsProvider ({ children }) { } // the specific wallet config on the server is stored in wallet.wallet - // on the client, it's stored in unnested + // on the client, it's stored unnested return { config: { ...config, ...w.wallet }, def } }) ?? [] - // merge wallets on name + // merge wallets on name like: { ...unconfigured, ...localConfig, ...serverConfig } const merged = {} for (const wallet of [...walletDefsOnly, ...localWallets, ...wallets]) { - merged[wallet.def.name] = { ...merged[wallet.def.name], ...wallet } + merged[wallet.def.name] = { + def: wallet.def, + config: { + ...merged[wallet.def.name]?.config, + ...Object.fromEntries( + Object.entries(wallet.config ?? {}).map(([key, value]) => [ + key, + value ?? merged[wallet.def.name]?.config?.[key] + ]) + ) + } + } } + + // sort by priority, then add status field return Object.values(merged) .sort(walletPrioritySort) .map(w => ({ ...w, status: w.config?.enabled ? Status.Enabled : Status.Disabled })) @@ -87,6 +100,7 @@ export function WalletsProvider ({ children }) { } if (wallet.config?.id) { + // set priority on server if it has an id await setWalletPriority({ variables: { id: wallet.config.id, priority } }) } else { const storageKey = getStorageKey(wallet.def.name, me?.id) @@ -95,9 +109,14 @@ export function WalletsProvider ({ children }) { window.localStorage.setItem(storageKey, JSON.stringify(newConfig)) } } - }, [setWalletPriority, me?.id]) + // reload local wallets if any priorities were set + if (priorities.length > 0) { + reloadLocalWallets() + } + }, [setWalletPriority, me?.id, reloadLocalWallets]) - // provides priority sorted wallets to children + // provides priority sorted wallets to children, a function to reload local wallets, + // and a function to set priorities return ( {children} @@ -117,6 +136,7 @@ export function useWallet (name) { return wallets.find(w => w.def.name === name) } + // return the first enabled wallet that is available and can send return wallets .filter(w => !w.def.isAvailable || w.def.isAvailable()) .filter(w => w.config?.enabled && canSend(w))[0] diff --git a/wallets/lightning-address/index.js b/wallets/lightning-address/index.js index 73cf5165..bd4992e3 100644 --- a/wallets/lightning-address/index.js +++ b/wallets/lightning-address/index.js @@ -1,10 +1,9 @@ -import { lnAddrAutowithdrawSchema } from '@/lib/validate' +import { lightningAddressValidator } from '@/lib/validate' export const name = 'lightning-address' export const shortName = 'lnAddr' export const walletType = 'LIGHTNING_ADDRESS' export const walletField = 'walletLightningAddress' -export const fieldValidation = lnAddrAutowithdrawSchema export const fields = [ { @@ -12,7 +11,12 @@ export const fields = [ label: 'lightning address', type: 'text', autoComplete: 'off', - serverOnly: true + serverOnly: true, + validate: lightningAddressValidator.test({ + name: 'address', + test: addr => !addr.toLowerCase().endsWith('@stacker.news'), + message: 'automated withdrawals must be external' + }) } ] diff --git a/wallets/lnbits/index.js b/wallets/lnbits/index.js index fd772efd..008c072f 100644 --- a/wallets/lnbits/index.js +++ b/wallets/lnbits/index.js @@ -1,15 +1,37 @@ -import { lnbitsSchema } from '@/lib/validate' +import { TOR_REGEXP } from '@/lib/url' +import { string } from '@/lib/yup' export const name = 'lnbits' export const walletType = 'LNBITS' export const walletField = 'walletLNbits' -export const fieldValidation = lnbitsSchema export const fields = [ { name: 'url', label: 'lnbits url', - type: 'text' + type: 'text', + required: true, + validate: process.env.NODE_ENV === 'development' + ? string() + .or([string().matches(/^(http:\/\/)?localhost:\d+$/), string().url()], 'invalid url') + .trim() + : string().url().trim() + .test(async (url, context) => { + if (TOR_REGEXP.test(url)) { + // allow HTTP and HTTPS over Tor + if (!/^https?:\/\//.test(url)) { + return context.createError({ message: 'http or https required' }) + } + return true + } + try { + // force HTTPS over clearnet + await string().https().validate(url) + } catch (err) { + return context.createError({ message: err.message }) + } + return true + }) }, { name: 'invoiceKey', @@ -17,7 +39,8 @@ export const fields = [ type: 'password', optional: 'for receiving', serverOnly: true, - editable: false + requiredWithout: 'adminKey', + validate: string().hex().length(32) }, { name: 'adminKey', @@ -25,7 +48,8 @@ export const fields = [ type: 'password', optional: 'for sending', clientOnly: true, - editable: false + requiredWithout: 'invoiceKey', + validate: string().hex().length(32) } ] diff --git a/wallets/lnc/index.js b/wallets/lnc/index.js index 58afdac6..51d41ad9 100644 --- a/wallets/lnc/index.js +++ b/wallets/lnc/index.js @@ -1,10 +1,10 @@ -import { lncSchema } from '@/lib/validate' +import bip39Words from '@/lib/bip39-words' +import { string } from '@/lib/yup' export const name = 'lnc' export const walletType = 'LNC' export const walletField = 'walletLNC' export const clientOnly = true -export const fieldValidation = lncSchema export const fields = [ { @@ -13,7 +13,25 @@ export const fields = [ type: 'password', help: 'We only need permissions for the uri `/lnrpc.Lightning/SendPaymentSync`\n\nCreate a budgeted account with narrow permissions:\n\n```$ litcli accounts create --balance ```\n\n```$ litcli sessions add --type custom --label --account_id --uri /lnrpc.Lightning/SendPaymentSync```\n\nGrab the `pairing_secret_mnemonic` from the output and paste it here.', editable: false, - clientOnly: true + clientOnly: true, + validate: string() + .test(async (value, context) => { + const words = value ? value.trim().split(/[\s]+/) : [] + for (const w of words) { + try { + await string().oneOf(bip39Words).validate(w) + } catch { + return context.createError({ message: `'${w}' is not a valid pairing phrase word` }) + } + } + if (words.length < 2) { + return context.createError({ message: 'needs at least two words' }) + } + if (words.length > 10) { + return context.createError({ message: 'max 10 words' }) + } + return true + }) }, { name: 'localKey', diff --git a/wallets/lnd/index.js b/wallets/lnd/index.js index bed10d62..bbedb050 100644 --- a/wallets/lnd/index.js +++ b/wallets/lnd/index.js @@ -1,9 +1,9 @@ -import { LNDAutowithdrawSchema } from '@/lib/validate' +import { isInvoicableMacaroon, isInvoiceMacaroon } from '@/lib/macaroon' +import { string } from '@/lib/yup' export const name = 'lnd' export const walletType = 'LND' export const walletField = 'walletLND' -export const fieldValidation = LNDAutowithdrawSchema export const fields = [ { @@ -13,7 +13,8 @@ export const fields = [ placeholder: '55.5.555.55:10001', hint: 'tor or clearnet', clear: true, - serverOnly: true + serverOnly: true, + validate: string().socket() }, { name: 'macaroon', @@ -26,7 +27,12 @@ export const fields = [ placeholder: 'AgEDbG5kAlgDChCn7YgfWX7uTkQQgXZ2uahNEgEwGhYKB2FkZHJlc3MSBHJlYWQSBXdyaXRlGhcKCGludm9pY2VzEgRyZWFkEgV3cml0ZRoPCgdvbmNoYWluEgRyZWFkAAAGIJkMBrrDV0npU90JV0TGNJPrqUD8m2QYoTDjolaL6eBs', hint: 'hex or base64 encoded', clear: true, - serverOnly: true + serverOnly: true, + validate: string().hexOrBase64().test({ + name: 'macaroon', + test: v => isInvoiceMacaroon(v) || isInvoicableMacaroon(v), + message: 'not an invoice macaroon or an invoicable macaroon' + }) }, { name: 'cert', @@ -36,7 +42,8 @@ export const fields = [ optional: 'optional if from [CA](https://en.wikipedia.org/wiki/Certificate_authority) (e.g. voltage)', hint: 'hex or base64 encoded', clear: true, - serverOnly: true + serverOnly: true, + validate: string().hexOrBase64() } ] diff --git a/wallets/nwc/index.js b/wallets/nwc/index.js index 2df5e9ca..49b478f9 100644 --- a/wallets/nwc/index.js +++ b/wallets/nwc/index.js @@ -1,12 +1,11 @@ import { Relay } from '@/lib/nostr' import { parseNwcUrl } from '@/lib/url' -import { nwcSchema } from '@/lib/validate' +import { string } from '@/lib/yup' import { finalizeEvent, nip04, verifyEvent } from 'nostr-tools' export const name = 'nwc' export const walletType = 'NWC' export const walletField = 'walletNWC' -export const fieldValidation = nwcSchema export const fields = [ { @@ -15,7 +14,8 @@ export const fields = [ type: 'password', optional: 'for sending', clientOnly: true, - editable: false + requiredWithout: 'nwcUrlRecv', + validate: string().nwcUrl() }, { name: 'nwcUrlRecv', @@ -23,7 +23,8 @@ export const fields = [ type: 'password', optional: 'for receiving', serverOnly: true, - editable: false + requiredWithout: 'nwcUrl', + validate: string().nwcUrl() } ] diff --git a/wallets/phoenixd/index.js b/wallets/phoenixd/index.js index ac5b6959..51b60405 100644 --- a/wallets/phoenixd/index.js +++ b/wallets/phoenixd/index.js @@ -1,16 +1,16 @@ -import { phoenixdSchema } from '@/lib/validate' +import { string } from '@/lib/yup' export const name = 'phoenixd' export const walletType = 'PHOENIXD' export const walletField = 'walletPhoenixd' -export const fieldValidation = phoenixdSchema // configure wallet fields export const fields = [ { name: 'url', label: 'url', - type: 'text' + type: 'text', + validate: string().url().trim() }, { name: 'primaryPassword', @@ -19,7 +19,8 @@ export const fields = [ optional: 'for sending', help: 'You can find the primary password as `http-password` in your phoenixd configuration file as mentioned [here](https://phoenix.acinq.co/server/api#security).', clientOnly: true, - editable: false + requiredWithout: 'secondaryPassword', + validate: string().length(64).hex() }, { name: 'secondaryPassword', @@ -28,7 +29,8 @@ export const fields = [ optional: 'for receiving', help: 'You can find the secondary password as `http-password-limited-access` in your phoenixd configuration file as mentioned [here](https://phoenix.acinq.co/server/api#security).', serverOnly: true, - editable: false + requiredWithout: 'primaryPassword', + validate: string().length(64).hex() } ] @@ -38,6 +40,3 @@ export const card = { subtitle: 'use [phoenixd](https://phoenix.acinq.co/server) for payments', badges: ['send & receive'] } - -// phoenixd::TODO -// set validation schema diff --git a/wallets/server.js b/wallets/server.js index 7e004aa6..26894041 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -42,11 +42,12 @@ export async function createInvoice (userId, { msats, description, descriptionHa for (const wallet of wallets) { const w = walletDefs.find(w => w.walletType === wallet.def.walletType) try { - const { walletType, walletField, createInvoice } = w if (!canReceive({ def: w, config: wallet })) { continue } + const { walletType, walletField, createInvoice } = w + const walletFull = await models.wallet.findFirst({ where: { userId, diff --git a/wallets/validate.js b/wallets/validate.js new file mode 100644 index 00000000..4040c826 --- /dev/null +++ b/wallets/validate.js @@ -0,0 +1,91 @@ +/* + we want to take all the validate members from the provided wallet + and compose into a single yup schema for formik validation ... + the validate member can be on of: + - a yup schema + - a function that throws on an invalid value + - a regular expression that must match +*/ + +import { autowithdrawSchemaMembers } from '@/lib/validate' +import * as Yup from '@/lib/yup' +import { canReceive } from './common' + +export default async function validateWallet (walletDef, data, options = { abortEarly: true, topLevel: true }) { + let schema = composeWalletSchema(walletDef) + + if (canReceive({ def: walletDef, config: data })) { + schema = schema.concat(autowithdrawSchemaMembers) + } + + await schema.validate(data, options) + + const casted = schema.cast(data, { assert: false }) + if (options.topLevel && walletDef.validate) { + await walletDef.validate(casted) + } + + return casted +} + +function createFieldSchema (name, validate) { + if (!validate) { + throw new Error(`No validation provided for field ${name}`) + } + + if (Yup.isSchema(validate)) { + // If validate is already a Yup schema, return it directly + return validate + } else if (typeof validate === 'function') { + // If validate is a function, create a custom Yup test + return Yup.mixed().test({ + name, + test: (value, context) => { + try { + validate(value) + return true + } catch (error) { + return context.createError({ message: error.message }) + } + } + }) + } else if (validate instanceof RegExp) { + // If validate is a regular expression, use Yup.matches + return Yup.string().matches(validate, `${name} is invalid`) + } else { + throw new Error(`validate for ${name} must be a yup schema, function, or regular expression`) + } +} + +function composeWalletSchema (walletDef) { + const { fields } = walletDef + + const schemaShape = fields.reduce((acc, field) => { + const { name, validate, optional, requiredWithout } = field + + acc[name] = createFieldSchema(name, validate) + + if (!optional) { + acc[name] = acc[name].required('Required') + } else if (requiredWithout) { + acc[name] = acc[name].when([requiredWithout], ([pairSetting], schema) => { + if (!pairSetting) return schema.required(`required if ${requiredWithout} not set`) + return Yup.mixed().or([schema.test({ + test: value => value !== pairSetting, + message: `${name} cannot be the same as ${requiredWithout}` + }), Yup.mixed().notRequired()]) + }) + } + + return acc + }, {}) + + // we use Object.keys(schemaShape).reverse() to avoid cyclic dependencies in Yup schema + // see https://github.com/jquense/yup/issues/176#issuecomment-367352042 + const composedSchema = Yup.object().shape(schemaShape, Object.keys(schemaShape).reverse()).concat(Yup.object({ + enabled: Yup.boolean(), + priority: Yup.number().min(0, 'must be at least 0').max(100, 'must be at most 100') + })) + + return composedSchema +} diff --git a/wallets/webln/index.js b/wallets/webln/index.js index 04a01075..4fe2efba 100644 --- a/wallets/webln/index.js +++ b/wallets/webln/index.js @@ -1,22 +1,15 @@ export const name = 'webln' export const walletType = 'WEBLN' export const walletField = 'walletWebLN' -export const clientOnly = true + +export const validate = ({ enabled }) => { + if (enabled && typeof window?.webln === 'undefined') { + throw new Error('no WebLN provider found') + } +} export const fields = [] -export const fieldValidation = ({ enabled }) => { - if (typeof window?.webln === 'undefined') { - // don't prevent disabling WebLN if no WebLN provider found - if (enabled) { - return { - enabled: 'no WebLN provider found' - } - } - } - return {} -} - export const card = { title: 'WebLN', subtitle: 'use a [WebLN provider](https://www.webln.guide/ressources/webln-providers) for payments', From aa04adaceae327aca09a79b2476ded211075f9b1 Mon Sep 17 00:00:00 2001 From: k00b Date: Fri, 25 Oct 2024 17:12:08 -0500 Subject: [PATCH 44/58] remove unused phoenix schema --- lib/validate.js | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/lib/validate.js b/lib/validate.js index 5e9b30c9..9c3efeb1 100644 --- a/lib/validate.js +++ b/lib/validate.js @@ -220,27 +220,6 @@ export const lnAddrSchema = ({ payerData, min, max, commentAllowed } = {}) => return accum }, {}))) -export const phoenixdSchema = object().shape({ - url: string().url().required('required').trim(), - primaryPassword: string().length(64).hex() - .when(['secondaryPassword'], ([secondary], schema) => { - if (!secondary) return schema.required('required if secondary password not set') - return schema.test({ - test: primary => secondary !== primary, - message: 'primary password cannot be the same as secondary password' - }) - }), - secondaryPassword: string().length(64).hex() - .when(['primaryPassword'], ([primary], schema) => { - if (!primary) return schema.required('required if primary password not set') - return schema.test({ - test: secondary => primary !== secondary, - message: 'secondary password cannot be the same as primary password' - }) - }), - ...autowithdrawSchemaMembers -}, ['primaryPassword', 'secondaryPassword']) - export function bountySchema (args) { return object({ title: titleValidator, From 84f5db44883bc02564266de19d357c20c51d317d Mon Sep 17 00:00:00 2001 From: k00b Date: Fri, 25 Oct 2024 17:12:18 -0500 Subject: [PATCH 45/58] update wallet readme --- wallets/README.md | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/wallets/README.md b/wallets/README.md index 71713516..49059c9d 100644 --- a/wallets/README.md +++ b/wallets/README.md @@ -55,10 +55,6 @@ This acts as an ID for this wallet on the client. It therefore must be unique ac - `shortName?: string` -Since `name` will also be used in [wallet logs](https://stacker.news/wallet/logs), you can specify a shorter name here which will be used in logs instead. - -- `perDevice?: boolean` - This is an optional value. Set this to true if your wallet needs to be configured per device and should thus not be synced across devices. - `fields: WalletField[]` @@ -69,17 +65,11 @@ Wallet fields define what this wallet requires for configuration and thus are us Wallet cards are the components you can see at [/settings/wallets](https://stacker.news/settings/wallets). This property customizes this card for this wallet. -- `fieldValidation: (config) => { [key: string]: string } | Yup.ObjectSchema` +- `validate: (config) => void` -This property defines how Formik should perform form-level validation. As mentioned in the [documentation](https://formik.org/docs/guides/validation#form-level-validation), Formik supports two ways to perform such validation. +This is an optional function that's passed the final config after it has been validated. Validation is otherwise done on each individual field in `fields. This function can be used to implement additional validation logic. If the validation fails, the function should throw an error with a descriptive message for the user. -If a function is used for `fieldValidation`, the built-in form-level validation is used via the [`validate`](https://formik.org/docs/guides/validation#validate) property of the Formik form component. - -If a [Yup object schema](https://github.com/jquense/yup?tab=readme-ov-file#object) is set, [`validationSchema`](https://formik.org/docs/guides/validation#validationschema) will be used instead. - -This validation is triggered on every submit and on every change after the first submit attempt. - -Refer to the [Formik documentation](https://formik.org/docs/guides/validation) for more details. +This validation is triggered on save. - `walletType?: string` @@ -108,6 +98,12 @@ The label of the configuration key. Will be shown to the user in the form. The input type that should be used for this value. For example, if the type is `password`, the input value will be hidden by default using a component for passwords. +- `validate: Yup.Schema | ((value) => void) | RegExp` + +This property defines how the value for this field should be validated. If a [Yup schema](https://github.com/jquense/yup?tab=readme-ov-file#object) is set, it will be used. Otherwise, the value will be validated by the function or the RegExp. When using a function, it is expected to throw an error with a descriptive message if the value is invalid. + +The validate field is required. + - `optional?: boolean | string = false` This property can be used to mark a wallet field as optional. If it is not set, we will assume this field is required else 'optional' will be shown to the user next to the label. You can use Markdown to customize this text. @@ -136,6 +132,16 @@ If a button to clear the input after it has been set should be shown, set this p This property controls the HTML `autocomplete` attribute. See [the documentation](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete) for possible values. Not setting it usually means that the user agent can use autocompletion. This property has no effect for passwords. Autocompletion is always turned off for passwords to prevent passwords getting saved for security reasons. +- `clientOnly?: boolean = false` + +If this property is set to `true`, this field is only available on the client. If the stacker has device sync enabled, this field will be encrypted before being synced across devices. Otherwise, the field will be stored only on the current device. + +- `serverOnly?: boolean = false` + +If this property is set to `true`, this field is only meant to be used on the server and is safe to sync across devices in plain text. + +If neither `clientOnly` nor `serverOnly` is set, the field is assumed to be used on both the client and the server and safe to sync across devices in plain text. + #### WalletCard - `title: string` From 4f7bdadd804794ef9a5a9ed2c911fcebf050f3d8 Mon Sep 17 00:00:00 2001 From: k00b Date: Fri, 25 Oct 2024 17:46:58 -0500 Subject: [PATCH 46/58] better wallet security banner --- components/banners.js | 13 ++++++------- pages/settings/wallets/[wallet].js | 10 +++++++++- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/components/banners.js b/components/banners.js index 915272e9..a36f5b54 100644 --- a/components/banners.js +++ b/components/banners.js @@ -122,18 +122,17 @@ export function WalletLimitBanner () { ) } -export function WalletSecurityBanner () { +export function WalletSecurityBanner ({ isActive }) { return ( - Wallet Security Disclaimer + Gunslingin' Safety Tips -

- Your wallet's credentials for spending are stored in the browser and never go to the server. - However, you should definitely set a budget in your wallet if you can. +

+ Listen up, pardner! Put a limit on yer spendin' wallet or hook up a wallet that's only for Stacker News. It'll keep them varmints from cleanin' out yer whole goldmine if they rustle up yer wallet.

-

- Also, for the time being, you will have to reenter your credentials on other devices. +

+ Your spending wallet's credentials are never sent to our servers in plain text. To sync across devices, enable device sync in your settings.

) diff --git a/pages/settings/wallets/[wallet].js b/pages/settings/wallets/[wallet].js index f563b13c..39c0d8a7 100644 --- a/pages/settings/wallets/[wallet].js +++ b/pages/settings/wallets/[wallet].js @@ -18,6 +18,7 @@ import { useMe } from '@/components/me' import validateWallet from '@/wallets/validate' import { ValidationError } from 'yup' import { useFormikContext } from 'formik' +import useVault from '@/components/vault/use-vault' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) @@ -68,7 +69,6 @@ export default function WalletSettings () {

{wallet?.def.card.title}

{wallet?.def.card.subtitle}
- {canSend(wallet) && } + {wallet && } +} + function ReceiveSettings ({ walletDef }) { const { values } = useFormikContext() return canReceive({ def: walletDef, config: values }) && From eae4c2b88223a84b87e2a5bca9947f0c3b591d2c Mon Sep 17 00:00:00 2001 From: k00b Date: Fri, 25 Oct 2024 17:48:56 -0500 Subject: [PATCH 47/58] cancel button spacing --- components/cancel-button.js | 2 +- pages/settings/wallets/[wallet].js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/components/cancel-button.js b/components/cancel-button.js index e9848d28..b823d50f 100644 --- a/components/cancel-button.js +++ b/components/cancel-button.js @@ -4,6 +4,6 @@ import Button from 'react-bootstrap/Button' export default function CancelButton ({ onClick }) { const router = useRouter() return ( - + ) } diff --git a/pages/settings/wallets/[wallet].js b/pages/settings/wallets/[wallet].js index 39c0d8a7..fbe624e9 100644 --- a/pages/settings/wallets/[wallet].js +++ b/pages/settings/wallets/[wallet].js @@ -18,7 +18,6 @@ import { useMe } from '@/components/me' import validateWallet from '@/wallets/validate' import { ValidationError } from 'yup' import { useFormikContext } from 'formik' -import useVault from '@/components/vault/use-vault' export const getServerSideProps = getGetServerSideProps({ authRequired: true }) From dce5762f6328f88879da669a60e067ca05ca5eb6 Mon Sep 17 00:00:00 2001 From: k00b Date: Sun, 27 Oct 2024 02:43:45 -0500 Subject: [PATCH 48/58] get vault working --- api/resolvers/vault.js | 15 +- api/resolvers/wallet.js | 34 ++-- api/typeDefs/vault.js | 2 + components/device-sync.js | 10 +- components/form.js | 57 +++--- components/qr.js | 18 +- components/vault/use-vault-configurator.js | 24 ++- components/vault/use-vault.js | 28 +-- fragments/vault.js | 13 +- fragments/wallet.js | 5 +- lib/validate.js | 6 + lib/yup.js | 27 ++- pages/settings/index.js | 5 + pages/settings/passphrase/index.js | 203 +++++++++++++++++++++ pages/settings/wallets/[wallet].js | 2 +- wallets/config.js | 2 +- wallets/index.js | 47 +++-- wallets/validate.js | 48 +++-- wallets/webln/index.js | 2 +- 19 files changed, 414 insertions(+), 134 deletions(-) create mode 100644 pages/settings/passphrase/index.js diff --git a/api/resolvers/vault.js b/api/resolvers/vault.js index 5915899f..8ee4237e 100644 --- a/api/resolvers/vault.js +++ b/api/resolvers/vault.js @@ -8,10 +8,8 @@ export default { const k = await models.vault.findUnique({ where: { - userId_key_ownerId_ownerType: { - key, - userId: me.id - } + key, + userId: me.id } }) return k @@ -19,7 +17,7 @@ export default { getVaultEntries: async (parent, { keysFilter }, { me, models }, info) => { if (!me) throw new GqlAuthenticationError() - const entries = await models.vault.findMany({ + const entries = await models.vaultEntry.findMany({ where: { userId: me.id, key: keysFilter?.length @@ -54,12 +52,13 @@ export default { } for (const entry of entries) { + console.log(entry) txs.push(models.vaultEntry.update({ where: { userId_key: { userId: me.id, key: entry.key } }, - data: { value: entry.value } + data: { value: entry.value, iv: entry.iv } })) } - await models.prisma.$transaction(txs) + await models.$transaction(txs) return true }, clearVault: async (parent, args, { me, models }) => { @@ -70,7 +69,7 @@ export default { data: { vaultKeyHash: '' } })) txs.push(models.vaultEntry.deleteMany({ where: { userId: me.id } })) - await models.prisma.$transaction(txs) + await models.$transaction(txs) return true } } diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 6711b20b..ecf5aacf 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -31,9 +31,7 @@ function injectResolvers (resolvers) { 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) - // TODO: our validation should be improved - const validData = await validateWallet(walletDef, { ...data, ...settings, vaultEntries }) + const validData = await validateWallet(walletDef, { ...data, ...settings, vaultEntries }, { serverSide: true }) 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] }) @@ -700,22 +698,27 @@ async function upsertWallet ( data: { enabled, priority, - [wallet.field]: { - update: { - where: { walletId: Number(id) }, - data: walletData - } - }, + // client only wallets has no walletData + ...(Object.keys(walletData).length > 0 + ? { + [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 + create: difference(vaultEntries, oldVaultEntries, 'key').map(({ key, iv, value }) => ({ + key, iv, value, userId: me.id })), - update: intersectionMerge(oldVaultEntries, vaultEntries, 'key').map(({ key, value }) => ({ + update: intersectionMerge(oldVaultEntries, vaultEntries, 'key').map(({ key, iv, value }) => ({ where: { userId_key: { userId: me.id, key } }, - data: { value } + data: { value, iv } })) } }, @@ -735,9 +738,8 @@ async function upsertWallet ( priority, userId: me.id, type: wallet.type, - [wallet.field]: { - create: walletData - }, + // client only wallets has no walletData + ...(Object.keys(walletData).length > 0 ? { [wallet.field]: { create: walletData } } : {}), vaultEntries: { createMany: { data: vaultEntries.map(({ key, value }) => ({ key, value, userId: me.id })) diff --git a/api/typeDefs/vault.js b/api/typeDefs/vault.js index a1600ea9..76be6a4e 100644 --- a/api/typeDefs/vault.js +++ b/api/typeDefs/vault.js @@ -4,6 +4,7 @@ export default gql` type VaultEntry { id: ID! key: String! + iv: String! value: String! createdAt: Date! updatedAt: Date! @@ -11,6 +12,7 @@ export default gql` input VaultEntryInput { key: String! + iv: String! value: String! walletId: ID } diff --git a/components/device-sync.js b/components/device-sync.js index 2a3084d5..99b590d8 100644 --- a/components/device-sync.js +++ b/components/device-sync.js @@ -15,11 +15,11 @@ import { useApolloClient } from '@apollo/client' export default function DeviceSync () { const { me } = useMe() const apollo = useApolloClient() - const [value, setVaultKey, clearVault, disconnectVault] = useVaultConfigurator() + const { key, setVaultKey, clearVault } = useVaultConfigurator() const showModal = useShowModal() const enabled = !!me?.privates?.vaultKeyHash - const connected = !!value?.key + const connected = !!key const manage = useCallback(async () => { if (enabled && connected) { @@ -27,7 +27,7 @@ export default function DeviceSync () {

Device sync is enabled!

- Sensitive data (like wallet credentials) is now securely synced between all connected devices. + Sensitive data (like wallet credentials) are now securely synced between all connected devices.

Disconnect to prevent this device from syncing data or to reset your passphrase. @@ -38,7 +38,7 @@ export default function DeviceSync () {