diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 8c768b3a..8414c0b9 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -202,6 +202,7 @@ export default gql` withdrawMaxFeeDefault: Int! autoWithdrawThreshold: Int vaultKeyHash: String + vaultKeyHashUpdatedAt: Date walletsUpdatedAt: Date } diff --git a/fragments/users.js b/fragments/users.js index 66107639..5e81c585 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -48,6 +48,7 @@ ${STREAK_FIELDS} wildWestMode disableFreebies vaultKeyHash + vaultKeyHashUpdatedAt walletsUpdatedAt showPassphrase } diff --git a/lib/format.js b/lib/format.js index 8a59ffb7..49159a77 100644 --- a/lib/format.js +++ b/lib/format.js @@ -250,3 +250,13 @@ export const truncateString = (str, maxLength, suffix = ' ...') => { return result } + +export function formatBytes (bytes) { + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let index = 0 + while (bytes >= 1024 && index < units.length - 1) { + bytes /= 1024 + index++ + } + return `${bytes.toFixed(2)} ${units[index]}` +} diff --git a/pages/wallets/debug.js b/pages/wallets/debug.js new file mode 100644 index 00000000..38d31019 --- /dev/null +++ b/pages/wallets/debug.js @@ -0,0 +1,15 @@ +import { getGetServerSideProps } from '@/api/ssrApollo' +import { WalletLayout, WalletLayoutHeader, WalletDebugSettings } from '@/wallets/client/components' + +export const getServerSideProps = getGetServerSideProps({}) + +export default function WalletDebug () { + return ( + +
+ wallet debug + +
+
+ ) +} diff --git a/prisma/migrations/20250721181629_vault_key_hash_updated_at/migration.sql b/prisma/migrations/20250721181629_vault_key_hash_updated_at/migration.sql new file mode 100644 index 00000000..dbc19f59 --- /dev/null +++ b/prisma/migrations/20250721181629_vault_key_hash_updated_at/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "vaultKeyHashUpdatedAt" TIMESTAMP(3); + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e6a0ca17..1c834f63 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -145,6 +145,7 @@ model User { oneDayReferrals OneDayReferral[] @relation("OneDayReferral_referrer") oneDayReferrees OneDayReferral[] @relation("OneDayReferral_referrees") vaultKeyHash String @default("") + vaultKeyHashUpdatedAt DateTime? showPassphrase Boolean @default(true) walletsUpdatedAt DateTime? proxyReceive Boolean @default(true) diff --git a/wallets/client/components/debug.js b/wallets/client/components/debug.js new file mode 100644 index 00000000..b4b62a4b --- /dev/null +++ b/wallets/client/components/debug.js @@ -0,0 +1,86 @@ +import { formatBytes } from '@/lib/format' +import { useEffect, useState } from 'react' +import { useKeyHash, useKeyUpdatedAt } from '@/wallets/client/context' +import { useRemoteKeyHash, useRemoteKeyHashUpdatedAt, useWalletsUpdatedAt } from '@/wallets/client/hooks' +import { timeSince } from '@/lib/time' + +export function WalletDebugSettings () { + const localKeyHash = useKeyHash() + const localKeyUpdatedAt = useKeyUpdatedAt() + const remoteKeyHash = useRemoteKeyHash() + const remoteKeyHashUpdatedAt = useRemoteKeyHashUpdatedAt() + const walletsUpdatedAt = useWalletsUpdatedAt() + const [persistent, setPersistent] = useState(null) + const [quota, setQuota] = useState(null) + const [usage, setUsage] = useState(null) + + useEffect(() => { + async function init () { + try { + const persistent = await navigator.storage.persisted() + setPersistent(persistent) + } catch (err) { + console.error('failed to check persistent storage:', err) + } + try { + const estimate = await navigator.storage.estimate() + setQuota(estimate.quota) + setUsage(estimate.usage) + } catch (err) { + console.error('failed to get estimate:', err) + } + } + init() + }, []) + + return ( +
+
+
persistent storage:
+
{persistent !== null ? persistent?.toString() : 'unknown'}
+
+
+
storage quota:
+
{quota !== null ? formatBytes(quota) : 'unknown'}
+
+
+
storage usage:
+
{usage !== null ? formatBytes(usage) : 'unknown'}
+
+
+
storage remaining:
+
{usage !== null && quota !== null ? formatBytes(quota - usage) : 'unknown'}
+
+
+
device key hash:
+
{localKeyHash ? shortHash(localKeyHash) : 'unknown'}
+
+
+
server key hash:
+
{remoteKeyHash ? shortHash(remoteKeyHash) : 'unknown'}
+
+
+
last device key update:
+
+ {localKeyUpdatedAt ? timeSince(localKeyUpdatedAt) : 'unknown'} +
+
+
+
last server key update:
+
+ {remoteKeyHashUpdatedAt ? timeSince(new Date(remoteKeyHashUpdatedAt).getTime()) : 'unknown'} +
+
+
+
last wallet update:
+
+ {walletsUpdatedAt ? timeSince(new Date(walletsUpdatedAt).getTime()) : 'unknown'} +
+
+
+ ) +} + +function shortHash (hash) { + return hash.slice(0, 6) + '...' + hash.slice(-6) +} diff --git a/wallets/client/components/index.js b/wallets/client/components/index.js index 76755a07..6c35ddef 100644 --- a/wallets/client/components/index.js +++ b/wallets/client/components/index.js @@ -4,3 +4,4 @@ export * from './forms' export * from './layout' export * from './passphrase' export * from './logger' +export * from './debug' diff --git a/wallets/client/context/hooks.js b/wallets/client/context/hooks.js index e64e3832..107cb9a5 100644 --- a/wallets/client/context/hooks.js +++ b/wallets/client/context/hooks.js @@ -160,7 +160,7 @@ export function useKeyInit () { const { key: randomKey, hash: randomHash } = await generateRandomKey() // run read and write in one transaction to avoid race conditions - const { key, hash } = await new Promise((resolve, reject) => { + const { key, hash, updatedAt } = await new Promise((resolve, reject) => { const tx = db.transaction('vault', 'readwrite') const read = tx.objectStore('vault').get('key') @@ -180,7 +180,8 @@ export function useKeyInit () { } // no key found, write and return generated random key - const write = tx.objectStore('vault').put({ key: randomKey, hash: randomHash }, 'key') + const updatedAt = Date.now() + const write = tx.objectStore('vault').put({ key: randomKey, hash: randomHash, updatedAt }, 'key') write.onerror = () => { reject(write.error) @@ -188,12 +189,12 @@ export function useKeyInit () { write.onsuccess = (event) => { // return key+hash we just wrote to db - resolve({ key: randomKey, hash: randomHash }) + resolve({ key: randomKey, hash: randomHash, updatedAt }) } } }) - await setKey({ key, hash }) + await setKey({ key, hash, updatedAt }, { updateDb: false }) } catch (err) { console.error('key init failed:', err) } diff --git a/wallets/client/context/provider.js b/wallets/client/context/provider.js index 3613bbdd..a86f2104 100644 --- a/wallets/client/context/provider.js +++ b/wallets/client/context/provider.js @@ -41,6 +41,11 @@ export function useKeyHash () { return keyHash } +export function useKeyUpdatedAt () { + const { keyUpdatedAt } = useContext(WalletsContext) + return keyUpdatedAt +} + export function useKeyError () { const { keyError } = useContext(WalletsContext) return keyError @@ -54,6 +59,7 @@ export default function WalletsProvider ({ children }) { templates: [], key: null, keyHash: null, + keyUpdatedAt: null, keyError: null }) diff --git a/wallets/client/context/reducer.js b/wallets/client/context/reducer.js index e1018737..688f82d6 100644 --- a/wallets/client/context/reducer.js +++ b/wallets/client/context/reducer.js @@ -40,7 +40,8 @@ export default function reducer (state, action) { return { ...state, key: action.key, - keyHash: action.hash + keyHash: action.hash, + keyUpdatedAt: action.updatedAt } case WRONG_KEY: return { diff --git a/wallets/client/hooks/crypto.js b/wallets/client/hooks/crypto.js index fbb2d6c1..c077eba8 100644 --- a/wallets/client/hooks/crypto.js +++ b/wallets/client/hooks/crypto.js @@ -42,10 +42,13 @@ export function useSetKey () { const dispatch = useWalletsDispatch() const updateKeyHash = useUpdateKeyHash() - return useCallback(async ({ key, hash }) => { - await set('vault', 'key', { key, hash }) + return useCallback(async ({ key, hash, updatedAt }, { updateDb = true } = {}) => { + if (updateDb) { + updatedAt = updatedAt ?? Date.now() + await set('vault', 'key', { key, hash, updatedAt }) + } await updateKeyHash(hash) - dispatch({ type: SET_KEY, key, hash }) + dispatch({ type: SET_KEY, key, hash, updatedAt }) }, [set, dispatch, updateKeyHash]) } @@ -86,6 +89,11 @@ export function useRemoteKeyHash () { return me?.privates?.vaultKeyHash } +export function useRemoteKeyHashUpdatedAt () { + const { me } = useMe() + return me?.privates?.vaultKeyHashUpdatedAt +} + export function useIsWrongKey () { const localHash = useKeyHash() const remoteHash = useRemoteKeyHash() diff --git a/wallets/client/hooks/query.js b/wallets/client/hooks/query.js index 8edf6b93..23288e81 100644 --- a/wallets/client/hooks/query.js +++ b/wallets/client/hooks/query.js @@ -22,7 +22,7 @@ import { UPDATE_KEY_HASH } from '@/wallets/client/fragments' import { useApolloClient, useMutation, useQuery } from '@apollo/client' -import { useDecryption, useEncryption, useSetKey, useWalletLogger, WalletStatus } from '@/wallets/client/hooks' +import { useDecryption, useEncryption, useSetKey, useWalletLogger, useWalletsUpdatedAt, WalletStatus } from '@/wallets/client/hooks' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { isEncryptedField, isTemplate, isWallet, protocolAvailable, protocolClientSchema, reverseProtocolRelationName @@ -109,12 +109,13 @@ function undoFieldAlias ({ id, ...wallet }) { function useRefetchOnChange (refetch) { const { me } = useMe() + const walletsUpdatedAt = useWalletsUpdatedAt() useEffect(() => { if (!me?.id) return refetch() - }, [refetch, me?.id, me?.privates?.walletsUpdatedAt]) + }, [refetch, me?.id, walletsUpdatedAt]) } export function useWalletQuery ({ id, name }) { diff --git a/wallets/client/hooks/wallet.js b/wallets/client/hooks/wallet.js index 95c14c41..8a439825 100644 --- a/wallets/client/hooks/wallet.js +++ b/wallets/client/hooks/wallet.js @@ -1,3 +1,4 @@ +import { useMe } from '@/components/me' import { useWallets } from '@/wallets/client/context' import protocols from '@/wallets/client/protocols' import { isWallet } from '@/wallets/lib/util' @@ -47,3 +48,8 @@ export function useWalletStatus (wallet) { return useMemo(() => ({ send: wallet.send, receive: wallet.receive }), [wallet]) } + +export function useWalletsUpdatedAt () { + const { me } = useMe() + return me?.privates?.walletsUpdatedAt +} diff --git a/wallets/server/resolvers/wallet.js b/wallets/server/resolvers/wallet.js index 54f4bdf7..d4816891 100644 --- a/wallets/server/resolvers/wallet.js +++ b/wallets/server/resolvers/wallet.js @@ -142,7 +142,11 @@ async function updateWalletEncryption (parent, { keyHash, wallets }, { me, model // make sure the user's vault key didn't change while we were updating the protocols await tx.user.update({ where: { id: me.id, vaultKeyHash: oldKeyHash }, - data: { vaultKeyHash: keyHash, showPassphrase: false } + data: { + vaultKeyHash: keyHash, + showPassphrase: false, + vaultKeyHashUpdatedAt: new Date() + } }) return true @@ -154,7 +158,7 @@ async function updateKeyHash (parent, { keyHash }, { me, models }) { const count = await models.$executeRaw` UPDATE users - SET "vaultKeyHash" = ${keyHash} + SET "vaultKeyHash" = ${keyHash}, "vaultKeyHashUpdatedAt" = NOW() WHERE id = ${me.id} AND "vaultKeyHash" = '' ` @@ -184,7 +188,11 @@ async function resetWallets (parent, { newKeyHash }, { me, models }) { await tx.user.update({ where: { id: me.id, vaultKeyHash: oldHash }, // TODO(wallet-v2): nullable vaultKeyHash column - data: { vaultKeyHash: newKeyHash, showPassphrase: true } + data: { + vaultKeyHash: newKeyHash, + showPassphrase: true, + vaultKeyHashUpdatedAt: new Date() + } }) })