258 lines
8.3 KiB
JavaScript
258 lines
8.3 KiB
JavaScript
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])
|
|
}
|