import { useCallback, useEffect, useState } from 'react' import { useLazyQuery } from '@apollo/client' import { FAILED_INVOICES } from '@/fragments/invoice' import { NORMAL_POLL_INTERVAL } from '@/lib/constants' import useInvoice from '@/components/use-invoice' import { useMe } from '@/components/me' import { useWalletsQuery, useWalletPayment, useGenerateRandomKey, useSetKey, useLoadKey, useLoadOldKey, useWalletMigrationMutation, CryptoKeyRequiredError, useIsWrongKey, useWalletLogger } from '@/wallets/client/hooks' import { WalletConfigurationError } from '@/wallets/client/errors' import { SET_WALLETS, WRONG_KEY, KEY_MATCH, useWalletsDispatch, WALLETS_QUERY_ERROR, KEY_STORAGE_UNAVAILABLE } from '@/wallets/client/context' import { useIndexedDB } from '@/components/use-indexeddb' export function useServerWallets () { const dispatch = useWalletsDispatch() const query = useWalletsQuery() useEffect(() => { if (query.error) { console.error('failed to fetch wallets:', query.error) dispatch({ type: WALLETS_QUERY_ERROR, error: query.error }) return } if (query.loading) return dispatch({ type: SET_WALLETS, wallets: query.data.wallets }) }, [query]) } export function useAutomatedRetries () { const waitForWalletPayment = useWalletPayment() const invoiceHelper = useInvoice() const [getFailedInvoices] = useLazyQuery(FAILED_INVOICES, { fetchPolicy: 'network-only', nextFetchPolicy: 'network-only' }) const { me } = useMe() const retry = useCallback(async (invoice) => { const newInvoice = await invoiceHelper.retry({ ...invoice, newAttempt: true }) try { await waitForWalletPayment(newInvoice) } catch (err) { if (err instanceof WalletConfigurationError) { // consume attempt by canceling invoice await invoiceHelper.cancel(newInvoice) } throw err } }, [invoiceHelper, waitForWalletPayment]) useEffect(() => { // we always retry failed invoices, even if the user has no wallets on any client // to make sure that failed payments will always show up in notifications eventually if (!me) return const retryPoll = async () => { let failedInvoices try { const { data, error } = await getFailedInvoices() if (error) throw error failedInvoices = data.failedInvoices } catch (err) { console.error('failed to fetch invoices to retry:', err) return } for (const inv of failedInvoices) { try { await retry(inv) } catch (err) { // some retries are expected to fail since only one client at a time is allowed to retry // these should show up as 'invoice not found' errors console.error('retry failed:', err) } } } let timeout, stopped const queuePoll = () => { timeout = setTimeout(async () => { try { await retryPoll() } catch (err) { // every error should already be handled in retryPoll // but this catch is a safety net to not trigger an unhandled promise rejection console.error('retry poll failed:', err) } if (!stopped) queuePoll() }, NORMAL_POLL_INTERVAL) } const stopPolling = () => { stopped = true clearTimeout(timeout) } queuePoll() return stopPolling }, [me?.id, getFailedInvoices, retry]) } export function useKeyInit () { const { me } = useMe() const dispatch = useWalletsDispatch() const wrongKey = useIsWrongKey() const logger = useWalletLogger() useEffect(() => { if (typeof window.indexedDB === 'undefined') { dispatch({ type: KEY_STORAGE_UNAVAILABLE }) } else if (wrongKey) { dispatch({ type: WRONG_KEY }) } else { dispatch({ type: KEY_MATCH }) } }, [wrongKey, dispatch]) const generateRandomKey = useGenerateRandomKey() const setKey = useSetKey() const loadKey = useLoadKey() const loadOldKey = useLoadOldKey() const [db, setDb] = useState(null) const { open } = useIndexedDB() useEffect(() => { if (!me?.id) return let db async function openDb () { try { db = await open() setDb(db) } catch (err) { console.error('failed to open indexeddb:', err) } } openDb() return () => { db?.close() setDb(null) } }, [me?.id, open]) useEffect(() => { if (!me?.id || !db) return async function keyInit () { try { // TODO(wallet-v2): remove migration code // and delete the old IndexedDB after wallet v2 has been released for some time // load old key and create random key before opening transaction in case we need them // because we can't run async code in a transaction because it will close the transaction // see https://javascript.info/indexeddb#transactions-autocommit const oldKeyAndHash = await loadOldKey() const { key: randomKey, hash: randomHash } = await generateRandomKey() // run read and write in one transaction to avoid race conditions const { key, hash, updatedAt } = await new Promise((resolve, reject) => { const tx = db.transaction('vault', 'readwrite') const read = tx.objectStore('vault').get('key') read.onerror = () => { logger.debug('key init: error reading key: ' + read.error) reject(read.error) } read.onsuccess = () => { if (read.result) { // return key+hash found in db logger.debug('key init: key found in IndexedDB') return resolve(read.result) } if (oldKeyAndHash) { // return key+hash found in old db logger.debug('key init: key found in old IndexedDB') return resolve(oldKeyAndHash) } // no key found, write and return generated random key const updatedAt = Date.now() const write = tx.objectStore('vault').put({ key: randomKey, hash: randomHash, updatedAt }, 'key') write.onerror = () => { logger.debug('key init: error writing new random key: ' + write.error) reject(write.error) } write.onsuccess = (event) => { // return key+hash we just wrote to db logger.debug('key init: saved new random key') resolve({ key: randomKey, hash: randomHash, updatedAt }) } } }) await setKey({ key, hash, updatedAt }, { updateDb: false }) } catch (err) { logger.debug('key init: error: ' + err) console.error('key init: error:', err) } } keyInit() }, [me?.id, db, generateRandomKey, loadOldKey, setKey, loadKey, logger]) } // TODO(wallet-v2): remove migration code // ============================================================= // ****** Below is the migration code for WALLET v1 -> v2 ****** // remove when we can assume migration is complete (if ever) // ============================================================= export function useWalletMigration () { const { me } = useMe() const { migrate: walletMigration, ready } = useWalletMigrationMutation() useEffect(() => { if (!me?.id || !ready) return async function migrate () { const localWallets = Object.entries(window.localStorage) .filter(([key]) => key.startsWith('wallet:')) .filter(([key]) => key.split(':').length < 3 || key.endsWith(me.id)) .reduce((acc, [key, value]) => { try { const config = JSON.parse(value) acc.push({ key, ...config }) } catch (err) { console.error(`useLocalWallets: ${key}: invalid JSON:`, err) } return acc }, []) await Promise.allSettled( localWallets.map(async ({ key, ...localWallet }) => { const name = key.split(':')[1].toUpperCase() try { await walletMigration({ ...localWallet, name }) window.localStorage.removeItem(key) } catch (err) { if (err instanceof CryptoKeyRequiredError) { // key not set yet, skip this wallet return } console.error(`${name}: wallet migration failed:`, err) } }) ) } migrate() }, [ready, me?.id, walletMigration]) }