sync/desync from localstorage on vault connect/disconnect

This commit is contained in:
k00b 2024-10-28 13:41:20 -05:00
parent 0c8180d89c
commit b1fc341017
16 changed files with 216 additions and 119 deletions

View File

@ -52,7 +52,6 @@ export default {
}
for (const entry of entries) {
console.log(entry)
txs.push(models.vaultEntry.update({
where: { userId_key: { userId: me.id, key: entry.key } },
data: { value: entry.value, iv: entry.iv }

View File

@ -31,10 +31,11 @@ function injectResolvers (resolvers) {
const resolverName = generateResolverName(walletDef.walletField)
console.log(resolverName)
resolvers.Mutation[resolverName] = async (parent, { settings, validateLightning, vaultEntries, ...data }, { me, models }) => {
console.log('resolving', resolverName, { settings, validateLightning, vaultEntries, ...data })
const validData = await validateWallet(walletDef, { ...data, ...settings, vaultEntries }, { serverSide: true })
if (validData) {
Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] })
Object.keys(validData).filter(key => key in settings).forEach(key => { settings[key] = validData[key] })
data && Object.keys(validData).filter(key => key in data).forEach(key => { data[key] = validData[key] })
settings && Object.keys(validData).filter(key => key in settings).forEach(key => { settings[key] = validData[key] })
}
return await upsertWallet({
@ -654,7 +655,7 @@ export const addWalletLog = async ({ wallet, level, message }, { models }) => {
}
async function upsertWallet (
{ wallet, testCreateInvoice }, { settings, data, vaultEntries = [] }, { me, models }) {
{ wallet, testCreateInvoice }, { settings, data, vaultEntries }, { me, models }) {
if (!me) {
throw new GqlAuthenticationError()
}
@ -674,11 +675,6 @@ async function upsertWallet (
}
const { id, enabled, priority, ...walletData } = data
const {
autoWithdrawThreshold,
autoWithdrawMaxFeePercent,
autoWithdrawMaxFeeTotal
} = settings
const txs = []
@ -709,18 +705,23 @@ async function upsertWallet (
}
}
: {}),
vaultEntries: {
deleteMany: difference(oldVaultEntries, vaultEntries, 'key').map(({ key }) => ({
userId: me.id, key
})),
create: difference(vaultEntries, oldVaultEntries, 'key').map(({ key, iv, value }) => ({
key, iv, value, userId: me.id
})),
update: intersectionMerge(oldVaultEntries, vaultEntries, 'key').map(({ key, iv, value }) => ({
where: { userId_key: { userId: me.id, key } },
data: { value, iv }
}))
}
...(vaultEntries
? {
vaultEntries: {
deleteMany: difference(oldVaultEntries, vaultEntries, 'key').map(({ key }) => ({
userId: me.id, key
})),
create: difference(vaultEntries, oldVaultEntries, 'key').map(({ key, iv, value }) => ({
key, iv, value, userId: me.id
})),
update: intersectionMerge(oldVaultEntries, vaultEntries, 'key').map(({ key, iv, value }) => ({
where: { userId_key: { userId: me.id, key } },
data: { value, iv }
}))
}
}
: {})
},
include: {
vaultEntries: true
@ -742,7 +743,7 @@ async function upsertWallet (
...(Object.keys(walletData).length > 0 ? { [wallet.field]: { create: walletData } } : {}),
vaultEntries: {
createMany: {
data: vaultEntries.map(({ key, value }) => ({ key, value, userId: me.id }))
data: vaultEntries?.map(({ key, iv, value }) => ({ key, iv, value, userId: me.id }))
}
}
}
@ -750,16 +751,14 @@ async function upsertWallet (
)
}
txs.push(
models.user.update({
where: { id: me.id },
data: {
autoWithdrawMaxFeePercent,
autoWithdrawThreshold,
autoWithdrawMaxFeeTotal
}
})
)
if (settings) {
txs.push(
models.user.update({
where: { id: me.id },
data: settings
})
)
}
txs.push(
models.walletLog.createMany({

View File

@ -17,7 +17,7 @@ function mutationTypeDefs () {
.filter(isServerField)
.map(fieldToGqlArgOptional)
if (serverFields.length > 0) args += serverFields.join(', ') + ','
args += 'enabled: Boolean, priority: Int, vaultEntries: [VaultEntryInput!], settings: AutowithdrawSettings!, validateLightning: Boolean'
args += 'enabled: Boolean, priority: Int, vaultEntries: [VaultEntryInput!], settings: AutowithdrawSettings, validateLightning: Boolean'
const resolverName = generateResolverName(w.walletField)
const typeDef = `${resolverName}(${args}): Wallet`
console.log(typeDef)

View File

@ -8,7 +8,7 @@ import Bolt11Info from './bolt11-info'
import { useQuery } from '@apollo/client'
import { INVOICE } from '@/fragments/wallet'
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
import { NoAttachedWalletError } from './payment'
import { NoAttachedWalletError } from '@/wallets/errors'
import ItemJob from './item-job'
import Item from './item'
import { CommentFlat } from './comment'

View File

@ -1,6 +1,7 @@
import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
import { useCallback, useState } from 'react'
import { InvoiceCanceledError, InvoiceExpiredError, useInvoice, useQrPayment, useWalletPayment } from './payment'
import { useInvoice, useQrPayment, useWalletPayment } from './payment'
import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors'
import { GET_PAID_ACTION } from '@/fragments/paidAction'
/*

View File

@ -18,7 +18,7 @@ const useImperativeQuery = (query) => {
return imperativelyCallQuery
}
export function useVaultConfigurator () {
export function useVaultConfigurator ({ onVaultKeySet, beforeDisconnectVault } = {}) {
const { me } = useMe()
const toaster = useToast()
const idbConfig = useMemo(() => ({ dbName: getDbName(me?.id, 'vault'), storeName: 'vault', options: {} }), [me?.id])
@ -63,6 +63,7 @@ export function useVaultConfigurator () {
})
const disconnectVault = useCallback(async () => {
beforeDisconnectVault?.()
await remove('key')
setKey(null)
setKeyHash(null)
@ -75,12 +76,16 @@ export function useVaultConfigurator () {
const vaultKey = await deriveKey(me.id, passphrase)
const { data } = await getVaultEntries()
// TODO: push any local configurations to the server so long as they don't conflict
// delete any locally stored configurations after vault key is set
const encrypt = async value => {
return await encryptValue(vaultKey.key, value)
}
const entries = []
for (const { key, iv, value } of data.getVaultEntries) {
const plainValue = await decryptValue(oldKeyValue.key, { iv, value })
entries.push({ key, ...await encryptValue(vaultKey.key, plainValue) })
if (oldKeyValue?.key) {
for (const { key, iv, value } of data.getVaultEntries) {
const plainValue = await decryptValue(oldKeyValue.key, { iv, value })
entries.push({ key, ...await encrypt(plainValue) })
}
}
await updateVaultKey({
@ -93,13 +98,15 @@ export function useVaultConfigurator () {
toaster.danger(error.graphQLErrors[0].message)
}
})
setKey(vaultKey)
setKeyHash(vaultKey.hash)
await set('key', vaultKey)
onVaultKeySet?.(encrypt).catch(console.error)
} catch (e) {
toaster.danger(e.message)
}
}, [getVaultEntries, updateVaultKey, set, get, remove])
}, [getVaultEntries, updateVaultKey, set, get, remove, onVaultKeySet])
return { key, setVaultKey, clearVault, disconnectVault }
}

View File

@ -3,7 +3,7 @@ import { COMMENTS, COMMENTS_ITEM_EXT_FIELDS } from './comments'
import { ITEM_FIELDS, ITEM_FULL_FIELDS } from './items'
import { SUB_FULL_FIELDS } from './subs'
const STREAK_FIELDS = gql`
export const STREAK_FIELDS = gql`
fragment StreakFields on User {
optional {
streak

View File

@ -99,6 +99,13 @@ function getClient (uri) {
Fact: {
keyFields: ['id', 'type']
},
Wallet: {
fields: {
vaultEntries: {
replace: true
}
}
},
Query: {
fields: {
sub: {

View File

@ -140,13 +140,13 @@ addMethod(string, 'hex', function (msg) {
addMethod(string, 'nwcUrl', function () {
return this.test({
test: async (nwcUrl, context) => {
test: (nwcUrl, context) => {
if (!nwcUrl) return true
// run validation in sequence to control order of errors
// inspired by https://github.com/jquense/yup/issues/851#issuecomment-1049705180
try {
await string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validate(nwcUrl)
string().matches(/^nostr\+?walletconnect:\/\//, { message: 'must start with nostr+walletconnect://' }).validateSync(nwcUrl)
let relayUrl, walletPubkey, secret
try {
({ relayUrl, walletPubkey, secret } = parseNwcUrl(nwcUrl))
@ -154,9 +154,9 @@ addMethod(string, 'nwcUrl', function () {
// invalid URL error. handle as if pubkey validation failed to not confuse user.
throw new Error('pubkey must be 64 hex chars')
}
await string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validate(walletPubkey)
await string().required('relay url required').trim().wss('relay must use wss://').validate(relayUrl)
await string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validate(secret)
string().required('pubkey required').trim().matches(NOSTR_PUBKEY_HEX, 'pubkey must be 64 hex chars').validateSync(walletPubkey)
string().required('relay url required').trim().wss('relay must use wss://').validateSync(relayUrl)
string().required('secret required').trim().matches(/^[0-9a-fA-F]{64}$/, 'secret must be 64 hex chars').validateSync(secret)
} catch (err) {
return context.createError({ message: err.message })
}
@ -172,7 +172,7 @@ addMethod(array, 'equalto', function equals (
return this.test({
name: 'equalto',
message: message || `${this.path} has invalid values`,
test: function (items) {
test: function (items = []) {
if (items.length < required.length) {
return this.createError({ message: `Expected ${this.path} to be at least ${required.length} items, but got ${items.length}` })
}

View File

@ -10,12 +10,14 @@ import { deviceSyncSchema } from '@/lib/validate'
import RefreshIcon from '@/svgs/refresh-line.svg'
import { useCallback, useEffect, useState } from 'react'
import { useToast } from '@/components/toast'
import { useWallets } from '@/wallets/index'
export const getServerSideProps = getGetServerSideProps({ authRequired: true })
export default function DeviceSync ({ ssrData }) {
const { me } = useMe()
const { key, setVaultKey, clearVault, disconnectVault } = useVaultConfigurator()
const { onVaultKeySet, beforeDisconnectVault } = useWallets()
const { key, setVaultKey, clearVault, disconnectVault } = useVaultConfigurator({ onVaultKeySet, beforeDisconnectVault })
const [passphrase, setPassphrase] = useState()
const setSeedPassphrase = useCallback(async (passphrase) => {
@ -51,7 +53,7 @@ function Connect ({ passphrase }) {
On your other devices, navigate to device sync settings and enter this exact passphrase.
</p>
<p className='line-height-md'>
Once you leave this page, this passphrase cannot be shown again. Connect all the devices you plan to use or write this passphrase down somewhere safe.
<strong>Once you leave this page, this passphrase cannot be shown again.</strong> Connect all the devices you plan to use or write this passphrase down somewhere safe.
</p>
<PasswordInput
label='passphrase'

View File

@ -96,3 +96,68 @@ export function canSend ({ def, config }) {
export function canReceive ({ def, config }) {
return def.fields.some(f => f.serverOnly) && isReceiveConfigured({ def, config })
}
export function siftConfig (fields, config) {
const sifted = {
clientOnly: {},
serverOnly: {},
shared: {},
serverWithShared: {},
clientWithShared: {},
settings: null
}
for (const [key, value] of Object.entries(config)) {
if (['id'].includes(key)) {
sifted.serverOnly[key] = value
continue
}
if (['autoWithdrawMaxFeePercent', 'autoWithdrawThreshold', 'autoWithdrawMaxFeeTotal'].includes(key)) {
sifted.serverOnly[key] = value
sifted.settings = { ...sifted.settings, [key]: value }
continue
}
const field = fields.find(({ name }) => name === key)
if (field) {
if (field.serverOnly) {
sifted.serverOnly[key] = value
} else if (field.clientOnly) {
sifted.clientOnly[key] = value
} else {
sifted.shared[key] = value
}
} else if (['enabled', 'priority'].includes(key)) {
sifted.shared[key] = value
}
}
sifted.serverWithShared = { ...sifted.shared, ...sifted.serverOnly }
sifted.clientWithShared = { ...sifted.shared, ...sifted.clientOnly }
return sifted
}
export async function upsertWalletVariables ({ def, config }, encrypt, append = {}) {
const { serverWithShared, settings, clientOnly } = siftConfig(def.fields, config)
// if we are disconnected from the vault, we leave vaultEntries undefined so we don't
// delete entries from connected devices
let vaultEntries
if (clientOnly && encrypt) {
vaultEntries = []
for (const [key, value] of Object.entries(clientOnly)) {
if (value) {
vaultEntries.push({ key, ...await encrypt(value) })
}
}
}
return { ...serverWithShared, settings, vaultEntries, ...append }
}
export async function saveWalletLocally (name, config, userId) {
const storageKey = getStorageKey(name, userId)
window.localStorage.setItem(storageKey, JSON.stringify(config))
}

View File

@ -1,7 +1,7 @@
import { useMe } from '@/components/me'
import useVault from '@/components/vault/use-vault'
import { useCallback } from 'react'
import { canReceive, canSend, getStorageKey } from './common'
import { canReceive, canSend, getStorageKey, saveWalletLocally, siftConfig, upsertWalletVariables } from './common'
import { useMutation } from '@apollo/client'
import { generateMutation } from './graphql'
import { REMOVE_WALLET } from '@/fragments/wallet'
@ -18,21 +18,15 @@ export function useWalletConfigurator (wallet) {
const [removeWallet] = useMutation(REMOVE_WALLET)
const _saveToServer = useCallback(async (serverConfig, clientConfig, validateLightning) => {
const { serverWithShared, settings, clientOnly } = siftConfig(wallet.def.fields, { ...serverConfig, ...clientConfig })
const vaultEntries = []
if (clientOnly && isActive) {
for (const [key, value] of Object.entries(clientOnly)) {
if (value) {
vaultEntries.push({ key, ...await encrypt(value) })
}
}
}
await upsertWallet({ variables: { ...serverWithShared, settings, validateLightning, vaultEntries } })
}, [encrypt, isActive, wallet.def.fields])
const variables = await upsertWalletVariables(
{ def: wallet.def, config: { ...serverConfig, ...clientConfig } },
isActive && encrypt,
{ validateLightning })
await upsertWallet({ variables })
}, [encrypt, isActive, wallet.def])
const _saveToLocal = useCallback(async (newConfig) => {
window.localStorage.setItem(getStorageKey(wallet.def.name, me?.id), JSON.stringify(newConfig))
saveWalletLocally(wallet.def.name, newConfig, me?.id)
reloadLocalWallets()
}, [me?.id, wallet.def.name, reloadLocalWallets])
@ -89,12 +83,13 @@ export function useWalletConfigurator (wallet) {
}
if (canReceive({ def: wallet.def, config: serverConfig })) {
await _saveToServer(serverConfig, clientConfig, validateLightning)
} else {
} else if (wallet.config.id) {
// if it previously had a server config, remove it
await _detachFromServer()
}
}
}, [isActive, _saveToServer, _saveToLocal, _validate, _detachFromLocal, _detachFromServer])
}, [isActive, wallet.def, _saveToServer, _saveToLocal, _validate,
_detachFromLocal, _detachFromServer])
const detach = useCallback(async () => {
if (isActive) {
@ -112,46 +107,3 @@ export function useWalletConfigurator (wallet) {
return { save, detach }
}
function siftConfig (fields, config) {
const sifted = {
clientOnly: {},
serverOnly: {},
shared: {},
serverWithShared: {},
clientWithShared: {},
settings: {}
}
for (const [key, value] of Object.entries(config)) {
if (['id'].includes(key)) {
sifted.serverOnly[key] = value
continue
}
if (['autoWithdrawMaxFeePercent', 'autoWithdrawThreshold', 'autoWithdrawMaxFeeTotal'].includes(key)) {
sifted.serverOnly[key] = value
sifted.settings[key] = value
continue
}
const field = fields.find(({ name }) => name === key)
if (field) {
if (field.serverOnly) {
sifted.serverOnly[key] = value
} else if (field.clientOnly) {
sifted.clientOnly[key] = value
} else {
sifted.shared[key] = value
}
} else {
sifted.shared[key] = value
}
}
sifted.serverWithShared = { ...sifted.shared, ...sifted.serverOnly }
sifted.clientWithShared = { ...sifted.shared, ...sifted.clientOnly }
return sifted
}

View File

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

View File

@ -1,13 +1,14 @@
import { useMe } from '@/components/me'
import { SET_WALLET_PRIORITY, WALLETS } from '@/fragments/wallet'
import { SSR } from '@/lib/constants'
import { useMutation, useQuery } from '@apollo/client'
import { useApolloClient, useMutation, useQuery } from '@apollo/client'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { getStorageKey, getWalletByType, Status, walletPrioritySort, canSend, isConfigured } from './common'
import { getStorageKey, getWalletByType, Status, walletPrioritySort, canSend, isConfigured, upsertWalletVariables, siftConfig, saveWalletLocally } from './common'
import useVault from '@/components/vault/use-vault'
import { useWalletLogger } from '@/components/wallet-logger'
import { bolt11Tags } from '@/lib/bolt11'
import walletDefs from 'wallets/client'
import { generateMutation } from './graphql'
const WalletsContext = createContext({
wallets: []
@ -31,11 +32,19 @@ function useLocalWallets () {
setWallets(wallets)
}, [me?.id, setWallets])
const removeWallets = useCallback(() => {
for (const wallet of wallets) {
const storageKey = getStorageKey(wallet.def.name, me?.id)
window.localStorage.removeItem(storageKey)
}
setWallets([])
}, [wallets, setWallets, me?.id])
useEffect(() => {
loadWallets()
}, [loadWallets])
return { wallets, reloadLocalWallets: loadWallets }
return { wallets, reloadLocalWallets: loadWallets, removeLocalWallets: removeWallets }
}
const walletDefsOnly = walletDefs.map(w => ({ def: w, config: {} }))
@ -43,9 +52,10 @@ const walletDefsOnly = walletDefs.map(w => ({ def: w, config: {} }))
export function WalletsProvider ({ children }) {
const { isActive, decrypt } = useVault()
const { me } = useMe()
const { wallets: localWallets, reloadLocalWallets } = useLocalWallets()
const { wallets: localWallets, reloadLocalWallets, removeLocalWallets } = useLocalWallets()
const [setWalletPriority] = useMutation(SET_WALLET_PRIORITY)
const [serverWallets, setServerWallets] = useState([])
const client = useApolloClient()
const { data, refetch } = useQuery(WALLETS,
SSR ? {} : { nextFetchPolicy: 'cache-and-network' })
@ -58,7 +68,7 @@ export function WalletsProvider ({ children }) {
}, [me?.privates?.walletsUpdatedAt, me?.privates?.vaultKeyHash, refetch])
useEffect(() => {
async function loadWallets () {
const loadWallets = async () => {
if (!data?.wallets) return
// form wallets into a list of { config, def }
const wallets = []
@ -79,6 +89,7 @@ export function WalletsProvider ({ children }) {
// on the client, it's stored unnested
wallets.push({ config: { ...config, ...w.wallet }, def })
}
setServerWallets(wallets)
}
loadWallets()
@ -108,6 +119,46 @@ export function WalletsProvider ({ children }) {
.map(w => ({ ...w, status: w.config?.enabled ? Status.Enabled : Status.Disabled }))
}, [serverWallets, localWallets])
const settings = useMemo(() => {
return {
autoWithdrawMaxFeePercent: me?.privates?.autoWithdrawMaxFeePercent,
autoWithdrawThreshold: me?.privates?.autoWithdrawThreshold,
autoWithdrawMaxFeeTotal: me?.privates?.autoWithdrawMaxFeeTotal
}
}, [me?.privates?.autoWithdrawMaxFeePercent, me?.privates?.autoWithdrawThreshold, me?.privates?.autoWithdrawMaxFeeTotal])
// if the vault key is set, and we have local wallets,
// we'll send any merged local wallets to the server, and delete them from local storage
const syncLocalWallets = useCallback(async encrypt => {
const walletsToSync = wallets.filter(w =>
// only sync wallets that have a local config
localWallets.some(localWallet => localWallet.def.name === w.def.name && !!localWallet.config)
)
if (encrypt && walletsToSync.length > 0) {
for (const wallet of walletsToSync) {
const mutation = generateMutation(wallet.def)
const append = {}
// if the wallet has server-only fields set, add the settings to the mutation variables
if (wallet.def.fields.some(f => f.serverOnly && wallet.config[f.name])) {
append.settings = settings
}
const variables = await upsertWalletVariables(wallet, encrypt, append)
await client.mutate({ mutation, variables })
}
removeLocalWallets()
}
}, [wallets, localWallets, removeLocalWallets, settings])
const unsyncLocalWallets = useCallback(() => {
for (const wallet of wallets) {
const { clientWithShared } = siftConfig(wallet.def.fields, wallet.config)
if (canSend({ def: wallet.def, config: clientWithShared })) {
saveWalletLocally(wallet.def.name, clientWithShared, me?.id)
}
}
reloadLocalWallets()
}, [wallets, me?.id, reloadLocalWallets])
const setPriorities = useCallback(async (priorities) => {
for (const { wallet, priority } of priorities) {
if (!isConfigured(wallet)) {
@ -133,7 +184,15 @@ export function WalletsProvider ({ children }) {
// provides priority sorted wallets to children, a function to reload local wallets,
// and a function to set priorities
return (
<WalletsContext.Provider value={{ wallets, reloadLocalWallets, setPriorities }}>
<WalletsContext.Provider
value={{
wallets,
reloadLocalWallets,
setPriorities,
onVaultKeySet: syncLocalWallets,
beforeDisconnectVault: unsyncLocalWallets
}}
>
{children}
</WalletsContext.Provider>
)
@ -172,5 +231,7 @@ export function useWallet (name) {
}
}, [wallet, logger])
if (!wallet) return null
return { ...wallet, sendPayment }
}

View File

@ -74,8 +74,11 @@ function composeWalletSchema (walletDef, serverSide) {
if (!optional) {
acc[name] = acc[name].required('Required')
} else if (requiredWithout) {
acc[name] = acc[name].when([requiredWithout], ([pairSetting], schema) => {
if (!pairSetting) return schema.required(`required if ${requiredWithout} not set`)
// if we are the server, the pairSetting will be in the vaultEntries array
acc[name] = acc[name].when([serverSide ? 'vaultEntries' : requiredWithout], ([pairSetting], schema) => {
if (!pairSetting || (serverSide && !pairSetting.some(v => v.key === requiredWithout))) {
return schema.required(`required if ${requiredWithout} not set`)
}
return Yup.mixed().or([schema.test({
test: value => value !== pairSetting,
message: `${name} cannot be the same as ${requiredWithout}`

View File

@ -1,6 +1,7 @@
import { useEffect } from 'react'
import { SSR } from '@/lib/constants'
export * from 'wallets/webln'
export const sendPayment = async (bolt11) => {
if (typeof window.webln === 'undefined') {
throw new Error('WebLN provider not found')