268 lines
9.4 KiB
JavaScript
268 lines
9.4 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, canReceive, supportsReceive, supportsSend, statusFromLog } from './common'
|
|
import useVault from '@/components/vault/use-vault'
|
|
import { useWalletLogger, useWalletLogs } from '@/components/wallet-logger'
|
|
import { decode as bolt11Decode } from 'bolt11'
|
|
import walletDefs from '@/wallets/client'
|
|
import { generateMutation } from './graphql'
|
|
import { formatSats } from '@/lib/format'
|
|
|
|
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 { logs } = useWalletLogs()
|
|
|
|
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, then add status field
|
|
return Object.values(merged)
|
|
.sort(walletPrioritySort)
|
|
.map(w => {
|
|
return {
|
|
...w,
|
|
support: {
|
|
recv: supportsReceive(w),
|
|
send: supportsSend(w)
|
|
},
|
|
status: {
|
|
any: w.config?.enabled && isConfigured(w) ? Status.Enabled : Status.Disabled,
|
|
send: w.config?.enabled && canSend(w) ? Status.Enabled : Status.Disabled,
|
|
recv: w.config?.enabled && canReceive(w) ? Status.Enabled : Status.Disabled
|
|
}
|
|
}
|
|
}).map(w => statusFromLog(w, logs))
|
|
}, [serverWallets, localWallets, logs])
|
|
|
|
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
|
|
}}
|
|
>
|
|
{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 decoded = bolt11Decode(bolt11)
|
|
logger.info(`↗ sending payment: ${formatSats(decoded.satoshis)}`, { bolt11 })
|
|
try {
|
|
const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger })
|
|
logger.ok(`↗ payment sent: ${formatSats(decoded.satoshis)}`, { bolt11, preimage })
|
|
} catch (err) {
|
|
const message = err.message || err.toString?.()
|
|
logger.error(`payment failed: ${message}`, { bolt11 })
|
|
throw err
|
|
}
|
|
}, [wallet, logger])
|
|
|
|
if (!wallet) return null
|
|
|
|
return { ...wallet, sendPayment }
|
|
}
|