* Poll failed invoices with visibility timeout * Don't return intermediate failed invoices * Don't retry too old invoices * Retry invoices on client * Only attempt payment 3 times * Fix fallbacks during last retry * Rename retry column to paymentAttempt * Fix no index used * Resolve TODOs * Use expiring locks * Better comments for constants * Acquire lock during retry * Use expiring lock in retry mutation * Use now() instead of CURRENT_TIMESTAMP * Cosmetic changes * Immediately show failed post payments in notifications * Update hasNewNotes * Never retry on user cancel For a consistent UX and less mental overhead, I decided to remove the exception for ITEM_CREATE where it would still retry in the background even though we want to show the payment failure immediately in notifications. * Fix notifications without pending retries missing if no send wallets If a stacker has no send wallets, they would miss notifications about failed payments because they would never get retried. This commit fixes this by making the notifications query aware if the stacker has send wallets. This way, it can tell if a notification will be retried or not. * Stop hiding userCancel in notifications As mentioned in a previous commit, I want to show anything that will not be attempted anymore in notifications. Before, I wanted to hide manually cancelled invoices but to not change experience unnecessarily and to decrease mental overhead, I changed my mind. * Also consider invoice.cancelledAt in notifications * Always retry failed payments, even without send wallets * Fix notification indicator on retry timeout * Set invoice.updated_at to date slightly in the future * Use default job priority * Stop retrying after one hour * Remove special case for ITEM_CREATE * Replace retryTimeout job with notification indicator query * Fix sortTime --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
305 lines
10 KiB
JavaScript
305 lines
10 KiB
JavaScript
import { useMe } from '@/components/me'
|
|
import { FAILED_INVOICES, SET_WALLET_PRIORITY, WALLETS } from '@/fragments/wallet'
|
|
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
|
|
import { useApolloClient, useLazyQuery, useMutation, useQuery } from '@apollo/client'
|
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
|
import { getStorageKey, getWalletByType, walletPrioritySort, canSend, isConfigured, upsertWalletVariables, siftConfig, saveWalletLocally } from './common'
|
|
import useVault from '@/components/vault/use-vault'
|
|
import walletDefs from '@/wallets/client'
|
|
import { generateMutation } from './graphql'
|
|
import { useWalletPayment } from './payment'
|
|
import useInvoice from '@/components/use-invoice'
|
|
import { WalletConfigurationError } from './errors'
|
|
|
|
const WalletsContext = createContext({
|
|
wallets: []
|
|
})
|
|
|
|
function useLocalWallets () {
|
|
const { me } = useMe()
|
|
const [wallets, setWallets] = useState([])
|
|
|
|
const loadWallets = useCallback(() => {
|
|
// form wallets from local storage into a list of { config, def }
|
|
const wallets = walletDefs.map(w => {
|
|
try {
|
|
const storageKey = getStorageKey(w.name, me?.id)
|
|
const config = window.localStorage.getItem(storageKey)
|
|
return { def: w, config: JSON.parse(config) }
|
|
} catch (e) {
|
|
return null
|
|
}
|
|
}).filter(Boolean)
|
|
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(() => {
|
|
// listen for changes to any wallet config in local storage
|
|
// from any window with the same origin
|
|
const handleStorage = (event) => {
|
|
if (event.key?.startsWith(getStorageKey(''))) {
|
|
loadWallets()
|
|
}
|
|
}
|
|
window.addEventListener('storage', handleStorage)
|
|
|
|
loadWallets()
|
|
return () => window.removeEventListener('storage', handleStorage)
|
|
}, [loadWallets])
|
|
|
|
return { wallets, reloadLocalWallets: loadWallets, removeLocalWallets: removeWallets }
|
|
}
|
|
|
|
const walletDefsOnly = walletDefs.map(w => ({ def: w, config: {} }))
|
|
|
|
export function WalletsProvider ({ children }) {
|
|
const { isActive, decrypt } = useVault()
|
|
const { me } = useMe()
|
|
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' })
|
|
|
|
// refetch wallets when the vault key hash changes or wallets are updated
|
|
useEffect(() => {
|
|
if (me?.privates?.walletsUpdatedAt) {
|
|
refetch()
|
|
}
|
|
}, [me?.privates?.walletsUpdatedAt, me?.privates?.vaultKeyHash, refetch])
|
|
|
|
useEffect(() => {
|
|
const loadWallets = async () => {
|
|
if (!data?.wallets) return
|
|
// form wallets into a list of { config, def }
|
|
const wallets = []
|
|
for (const w of data.wallets) {
|
|
const def = getWalletByType(w.type)
|
|
const { vaultEntries, ...config } = w
|
|
if (isActive) {
|
|
for (const { key, iv, value } of vaultEntries) {
|
|
try {
|
|
config[key] = await decrypt({ iv, value })
|
|
} catch (e) {
|
|
console.error('error decrypting vault entry', e)
|
|
}
|
|
}
|
|
}
|
|
|
|
// the specific wallet config on the server is stored in wallet.wallet
|
|
// on the client, it's stored unnested
|
|
wallets.push({ config: { ...config, ...w.wallet }, def, vaultEntries })
|
|
}
|
|
|
|
setServerWallets(wallets)
|
|
}
|
|
loadWallets()
|
|
}, [data?.wallets, decrypt, isActive])
|
|
|
|
// merge wallets on name like: { ...unconfigured, ...localConfig, ...serverConfig }
|
|
const wallets = useMemo(() => {
|
|
const merged = {}
|
|
for (const wallet of [...walletDefsOnly, ...localWallets, ...serverWallets]) {
|
|
merged[wallet.def.name] = {
|
|
def: {
|
|
...wallet.def,
|
|
requiresConfig: wallet.def.fields.length > 0
|
|
},
|
|
config: {
|
|
...merged[wallet.def.name]?.config,
|
|
...Object.fromEntries(
|
|
Object.entries(wallet.config ?? {}).map(([key, value]) => [
|
|
key,
|
|
value ?? merged[wallet.def.name]?.config?.[key]
|
|
])
|
|
)
|
|
},
|
|
vaultEntries: wallet.vaultEntries
|
|
}
|
|
}
|
|
|
|
// sort by priority
|
|
return Object.values(merged).sort(walletPrioritySort)
|
|
}, [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])
|
|
|
|
// whenever 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)) {
|
|
throw new Error(`cannot set priority for unconfigured wallet: ${wallet.def.name}`)
|
|
}
|
|
|
|
if (wallet.config?.id) {
|
|
// set priority on server if it has an id
|
|
await setWalletPriority({ variables: { id: wallet.config.id, priority } })
|
|
} else {
|
|
const storageKey = getStorageKey(wallet.def.name, me?.id)
|
|
const config = window.localStorage.getItem(storageKey)
|
|
const newConfig = { ...JSON.parse(config), priority }
|
|
window.localStorage.setItem(storageKey, JSON.stringify(newConfig))
|
|
}
|
|
}
|
|
// reload local wallets if any priorities were set
|
|
if (priorities.length > 0) {
|
|
reloadLocalWallets()
|
|
}
|
|
}, [setWalletPriority, me?.id, reloadLocalWallets])
|
|
|
|
// 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,
|
|
onVaultKeySet: syncLocalWallets,
|
|
beforeDisconnectVault: unsyncLocalWallets,
|
|
removeLocalWallets
|
|
}}
|
|
>
|
|
<RetryHandler>
|
|
{children}
|
|
</RetryHandler>
|
|
</WalletsContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useWallets () {
|
|
return useContext(WalletsContext)
|
|
}
|
|
|
|
export function useWallet (name) {
|
|
const { wallets } = useWallets()
|
|
return wallets.find(w => w.def.name === name)
|
|
}
|
|
|
|
export function useSendWallets () {
|
|
const { wallets } = useWallets()
|
|
// return all enabled wallets that are available and can send
|
|
return useMemo(() => wallets
|
|
.filter(w => !w.def.isAvailable || w.def.isAvailable())
|
|
.filter(w => w.config?.enabled && canSend(w)), [wallets])
|
|
}
|
|
|
|
function RetryHandler ({ children }) {
|
|
const wallets = useSendWallets()
|
|
const waitForWalletPayment = useWalletPayment()
|
|
const invoiceHelper = useInvoice()
|
|
const [getFailedInvoices] = useLazyQuery(FAILED_INVOICES, { fetchPolicy: 'network-only', nextFetchPolicy: 'network-only' })
|
|
|
|
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
|
|
|
|
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
|
|
}, [wallets, getFailedInvoices, retry])
|
|
|
|
return children
|
|
}
|