stacker.news/wallets/index.js

238 lines
8.3 KiB
JavaScript

import { useMe } from '@/components/me'
import { SET_WALLET_PRIORITY, WALLETS } from '@/fragments/wallet'
import { SSR } from '@/lib/constants'
import { useApolloClient, useMutation, useQuery } from '@apollo/client'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
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: []
})
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(() => {
loadWallets()
}, [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 })
}
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,
config: {
...merged[wallet.def.name]?.config,
...Object.fromEntries(
Object.entries(wallet.config ?? {}).map(([key, value]) => [
key,
value ?? merged[wallet.def.name]?.config?.[key]
])
)
}
}
}
// sort by priority, then add status field
return Object.values(merged)
.sort(walletPrioritySort)
.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)) {
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
}}
>
{children}
</WalletsContext.Provider>
)
}
export function useWallets () {
return useContext(WalletsContext)
}
export function useWallet (name) {
const { wallets } = useWallets()
const wallet = useMemo(() => {
if (name) {
return wallets.find(w => w.def.name === name)
}
// return the first enabled wallet that is available and can send
return wallets
.filter(w => !w.def.isAvailable || w.def.isAvailable())
.filter(w => w.config?.enabled && canSend(w))[0]
}, [wallets, name])
const { logger } = useWalletLogger(wallet?.def)
const sendPayment = useCallback(async (bolt11) => {
const hash = bolt11Tags(bolt11).payment_hash
logger.info('sending payment:', `payment_hash=${hash}`)
try {
const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger })
logger.ok('payment successful:', `payment_hash=${hash}`, `preimage=${preimage}`)
} catch (err) {
const message = err.message || err.toString?.()
logger.error('payment failed:', `payment_hash=${hash}`, message)
throw err
}
}, [wallet, logger])
if (!wallet) return null
return { ...wallet, sendPayment }
}