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