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