import { WALLET, UPSERT_WALLET_RECEIVE_BLINK, UPSERT_WALLET_RECEIVE_CLN_REST, UPSERT_WALLET_RECEIVE_LIGHTNING_ADDRESS, UPSERT_WALLET_RECEIVE_LNBITS, UPSERT_WALLET_RECEIVE_LND_GRPC, UPSERT_WALLET_RECEIVE_NWC, UPSERT_WALLET_RECEIVE_PHOENIXD, UPSERT_WALLET_SEND_BLINK, UPSERT_WALLET_SEND_LNBITS, UPSERT_WALLET_SEND_LNC, UPSERT_WALLET_SEND_NWC, UPSERT_WALLET_SEND_PHOENIXD, UPSERT_WALLET_SEND_WEBLN, WALLETS, REMOVE_WALLET_PROTOCOL, UPDATE_WALLET_ENCRYPTION, RESET_WALLETS, DISABLE_PASSPHRASE_EXPORT, SET_WALLET_PRIORITIES, UPDATE_KEY_HASH, TEST_WALLET_RECEIVE_LNBITS, TEST_WALLET_RECEIVE_PHOENIXD, TEST_WALLET_RECEIVE_BLINK, TEST_WALLET_RECEIVE_LIGHTNING_ADDRESS, TEST_WALLET_RECEIVE_NWC, TEST_WALLET_RECEIVE_CLN_REST, TEST_WALLET_RECEIVE_LND_GRPC } from '@/wallets/client/fragments' import { gql, useApolloClient, useMutation, useQuery } from '@apollo/client' import { useDecryption, useEncryption, useSetKey, useWalletLogger, useWalletsUpdatedAt, WalletStatus } from '@/wallets/client/hooks' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { isEncryptedField, isTemplate, isWallet, protocolAvailable, protocolClientSchema, reverseProtocolRelationName } from '@/wallets/lib/util' import { protocolTestSendPayment } from '@/wallets/client/protocols' import { timeoutSignal } from '@/lib/time' import { WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants' import { useToast } from '@/components/toast' import { useMe } from '@/components/me' import { useWallets, useWalletsLoading } from '@/wallets/client/context' import { requestPersistentStorage } from '@/components/use-indexeddb' export function useWalletsQuery () { const { me } = useMe() const query = useQuery(WALLETS, { skip: !me }) const [wallets, setWallets] = useState(null) const [error, setError] = useState(null) const { decryptWallet, ready } = useWalletDecryption() useEffect(() => { if (!query.data?.wallets || !ready) return Promise.all( query.data?.wallets.map(w => decryptWallet(w)) ) .then(wallets => wallets.map(protocolCheck)) .then(wallets => wallets.map(undoFieldAlias)) .then(wallets => { setWallets(wallets) setError(null) }) .catch(err => { console.error('failed to decrypt wallets:', err) setWallets([]) // OperationError from the Web Crypto API does not have a message setError(new Error('decryption error: ' + (err.message || err.name))) }) }, [query.data, decryptWallet, ready]) useRefetchOnChange(query.refetch) return useMemo(() => ({ ...query, error: error ?? query.error, loading: !wallets, data: wallets ? { wallets } : null }), [query, error, wallets]) } function protocolCheck (wallet) { if (isTemplate(wallet)) return wallet const protocols = wallet.protocols.map(protocol => { return { ...protocol, enabled: protocol.enabled && protocolAvailable(protocol) } }) const sendEnabled = protocols.some(p => p.send && p.enabled) const receiveEnabled = protocols.some(p => !p.send && p.enabled) return { ...wallet, send: !sendEnabled ? WalletStatus.DISABLED : wallet.send, receive: !receiveEnabled ? WalletStatus.DISABLED : wallet.receive, protocols } } function undoFieldAlias ({ id, ...wallet }) { // Just like for encrypted fields, we have to use a field alias for the name field of templates // because of https://github.com/graphql/graphql-js/issues/53. // We undo this here so this only affects the GraphQL layer but not the rest of the code. if (isTemplate(wallet)) { return { ...wallet, name: id } } if (!wallet.template) return wallet const { id: templateId, ...template } = wallet.template return { id, ...wallet, template: { name: templateId, ...template } } } function useRefetchOnChange (refetch) { const { me } = useMe() const walletsUpdatedAt = useWalletsUpdatedAt() useEffect(() => { if (!me?.id) return refetch() }, [refetch, me?.id, walletsUpdatedAt]) } export function useWalletQuery ({ id, name }) { const { me } = useMe() const query = useQuery(WALLET, { variables: { id, name }, skip: !me }) const [wallet, setWallet] = useState(null) const { decryptWallet, ready } = useWalletDecryption() useEffect(() => { if (!query.data?.wallet || !ready) return decryptWallet(query.data?.wallet) .then(protocolCheck) .then(undoFieldAlias) .then(wallet => setWallet(wallet)) .catch(err => { console.error('failed to decrypt wallet:', err) }) }, [query.data, decryptWallet, ready]) return useMemo(() => ({ ...query, loading: !wallet, data: wallet ? { wallet } : null }), [query, wallet]) } export function useWalletProtocolUpsert (wallet, protocol) { const mutation = getWalletProtocolUpsertMutation(protocol) const [mutate] = useMutation(mutation) const { encryptConfig } = useEncryptConfig(protocol) const testSendPayment = useTestSendPayment(protocol) const testCreateInvoice = useTestCreateInvoice(protocol) const logger = useWalletLogger(protocol) return useCallback(async (values) => { logger.info('saving wallet ...') if (isTemplate(protocol)) { values.enabled = true } // skip network tests if we're disabling the wallet if (values.enabled) { try { if (protocol.send) { const additionalValues = await testSendPayment(values) values = { ...values, ...additionalValues } } else { await testCreateInvoice(values) } } catch (err) { logger.error(err.message) throw err } } const encrypted = await encryptConfig(values) const variables = encrypted if (isWallet(wallet)) { variables.walletId = wallet.id } else { variables.templateName = wallet.name } let updatedWallet try { const { data } = await mutate({ variables }) logger.ok('wallet saved') updatedWallet = Object.values(data)[0] } catch (err) { logger.error(err.message) throw err } requestPersistentStorage() return updatedWallet }, [wallet, protocol, logger, testSendPayment, testCreateInvoice, encryptConfig, mutate]) } export function useLightningAddressUpsert () { // TODO(wallet-v2): parse domain from address input to use correct wallet template // useWalletProtocolUpsert needs to support passing in the wallet in the callback for that const wallet = { name: 'LN_ADDR', __typename: 'WalletTemplate' } const protocol = { name: 'LN_ADDR', send: false, __typename: 'WalletProtocolTemplate' } return useWalletProtocolUpsert(wallet, protocol) } export function useWalletProtocolRemove (protocol) { const [mutate] = useMutation(REMOVE_WALLET_PROTOCOL) const toaster = useToast() return useCallback(async () => { try { await mutate({ variables: { id: protocol.id } }) toaster.success('protocol detached') } catch (err) { toaster.danger('failed to detach protocol: ' + err.message) } }, [protocol?.id, mutate, toaster]) } export function useWalletEncryptionUpdate () { const wallets = useWallets() const [mutate] = useMutation(UPDATE_WALLET_ENCRYPTION) const setKey = useSetKey() const { encryptConfig } = useEncryptConfig() return useCallback(async ({ key, hash }) => { const encrypted = await Promise.all( wallets.map(async d => ({ ...d, protocols: await Promise.all( d.protocols.map(p => { return encryptConfig(p.config, { key, hash, protocol: p }) })) })) ) const data = encrypted.map(wallet => ({ id: wallet.id, protocols: wallet.protocols.map(protocol => { const { id, __typename: relationName, ...config } = protocol const { name, send } = reverseProtocolRelationName(relationName) return { name, send, config } }) })) await mutate({ variables: { keyHash: hash, wallets: data } }) await setKey({ key, hash }) }, [wallets, mutate, setKey, encryptConfig]) } export function useWalletReset () { const [mutate] = useMutation(RESET_WALLETS) return useCallback(async ({ newKeyHash }) => { await mutate({ variables: { newKeyHash } }) }, [mutate]) } export function useDisablePassphraseExport () { const [mutate] = useMutation(DISABLE_PASSPHRASE_EXPORT) return useCallback(async () => { await mutate() }, [mutate]) } export function useSetWalletPriorities () { const [mutate] = useMutation(SET_WALLET_PRIORITIES) const toaster = useToast() return useCallback(async (wallets) => { const priorities = wallets.map((wallet, index) => ({ id: wallet.id, priority: index })) try { await mutate({ variables: { priorities } }) } catch (err) { console.error('failed to update wallet priorities:', err) toaster.danger('failed to update wallet priorities') } }, [mutate, toaster]) } // we only have test mutations for receive protocols and useMutation throws if we pass null to it, // so we use this placeholder mutation in such cases to respect the rules of hooks. // (the mutation would throw if called but we make sure to never call it.) const NOOP_MUTATION = gql`mutation noop { noop }` function getWalletProtocolUpsertMutation (protocol) { switch (protocol.name) { case 'LNBITS': return protocol.send ? UPSERT_WALLET_SEND_LNBITS : UPSERT_WALLET_RECEIVE_LNBITS case 'PHOENIXD': return protocol.send ? UPSERT_WALLET_SEND_PHOENIXD : UPSERT_WALLET_RECEIVE_PHOENIXD case 'BLINK': return protocol.send ? UPSERT_WALLET_SEND_BLINK : UPSERT_WALLET_RECEIVE_BLINK case 'LN_ADDR': return protocol.send ? NOOP_MUTATION : UPSERT_WALLET_RECEIVE_LIGHTNING_ADDRESS case 'NWC': return protocol.send ? UPSERT_WALLET_SEND_NWC : UPSERT_WALLET_RECEIVE_NWC case 'CLN_REST': return protocol.send ? NOOP_MUTATION : UPSERT_WALLET_RECEIVE_CLN_REST case 'LND_GRPC': return protocol.send ? NOOP_MUTATION : UPSERT_WALLET_RECEIVE_LND_GRPC case 'LNC': return protocol.send ? UPSERT_WALLET_SEND_LNC : NOOP_MUTATION case 'WEBLN': return protocol.send ? UPSERT_WALLET_SEND_WEBLN : NOOP_MUTATION default: return NOOP_MUTATION } } function getWalletProtocolTestMutation (protocol) { if (protocol.send) return NOOP_MUTATION switch (protocol.name) { case 'LNBITS': return TEST_WALLET_RECEIVE_LNBITS case 'PHOENIXD': return TEST_WALLET_RECEIVE_PHOENIXD case 'BLINK': return TEST_WALLET_RECEIVE_BLINK case 'LN_ADDR': return TEST_WALLET_RECEIVE_LIGHTNING_ADDRESS case 'NWC': return TEST_WALLET_RECEIVE_NWC case 'CLN_REST': return TEST_WALLET_RECEIVE_CLN_REST case 'LND_GRPC': return TEST_WALLET_RECEIVE_LND_GRPC default: return NOOP_MUTATION } } function useTestSendPayment (protocol) { return useCallback(async (values) => { return await protocolTestSendPayment( protocol, values, { signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS) } ) }, [protocol]) } function useTestCreateInvoice (protocol) { const mutation = getWalletProtocolTestMutation(protocol) const [testCreateInvoice] = useMutation(mutation) return useCallback(async (values) => { return await testCreateInvoice({ variables: values }) }, [testCreateInvoice]) } function useWalletDecryption () { const { decryptConfig, ready } = useDecryptConfig() const decryptWallet = useCallback(async wallet => { if (!isWallet(wallet)) return wallet const protocols = await Promise.all( wallet.protocols.map( async protocol => ({ ...protocol, config: await decryptConfig(protocol.config) }) ) ) return { ...wallet, protocols } }, [decryptConfig]) return useMemo(() => ({ decryptWallet, ready }), [decryptWallet, ready]) } function useDecryptConfig () { const { decrypt, ready } = useDecryption() const decryptConfig = useCallback(async (config) => { return Object.fromEntries( await Promise.all( Object.entries(config) .map( async ([key, value]) => { if (!isEncrypted(value)) return [key, value] // undo the field aliases we had to use because of https://github.com/graphql/graphql-js/issues/53 // so we can pretend the GraphQL API returns the fields as they are named in the schema let renamed = key.replace(/^encrypted/, '') renamed = renamed.charAt(0).toLowerCase() + renamed.slice(1) return [ renamed, await decrypt(value) ] } ) ) ) }, [decrypt]) return useMemo(() => ({ decryptConfig, ready }), [decryptConfig, ready]) } function isEncrypted (value) { return value.__typename === 'VaultEntry' } function useEncryptConfig (defaultProtocol, options = {}) { const { encrypt, ready } = useEncryption(options) const encryptConfig = useCallback(async (config, { key: cryptoKey, hash, protocol } = {}) => { return Object.fromEntries( await Promise.all( Object.entries(config) .map( async ([fieldKey, value]) => { if (!isEncryptedField(protocol ?? defaultProtocol, fieldKey)) return [fieldKey, value] return [ fieldKey, await encrypt(value, { key: cryptoKey, hash }) ] } ) ) ) }, [defaultProtocol, encrypt]) return useMemo(() => ({ encryptConfig, ready }), [encryptConfig, ready]) } // 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 useWalletMigrationMutation () { const wallets = useWallets() const loading = useWalletsLoading() const client = useApolloClient() const { encryptConfig, ready } = useEncryptConfig() // XXX We use a ref for the wallets to avoid duplicate wallets // Without a ref, the migrate callback would depend on the wallets and thus update every time the migration creates a wallet. // This update would then cause the useEffect in wallets/client/context/hooks that triggers the migration to run again before the first migration is complete. const walletsRef = useRef(wallets) useEffect(() => { if (!loading) walletsRef.current = wallets }, [loading]) const migrate = useCallback(async ({ name, enabled, ...configV1 }) => { const protocol = { name, send: true } const configV2 = migrateConfig(protocol, configV1) const isSameProtocol = (p) => { const sameName = p.name === protocol.name const sameSend = p.send === protocol.send const sameConfig = Object.keys(p.config) .filter(k => !['__typename', 'id'].includes(k)) .every(k => p.config[k] === configV2[k]) return sameName && sameSend && sameConfig } const exists = walletsRef.current.some(w => w.name === name && w.protocols.some(isSameProtocol)) if (exists) return const schema = protocolClientSchema(protocol) await schema.validate(configV2) const encrypted = await encryptConfig(configV2, { protocol }) // decide if we create a new wallet (templateName) or use an existing one (walletId) const templateName = getWalletTemplateName(protocol) let walletId const wallet = walletsRef.current.find(w => w.name === name && !w.protocols.some(p => p.name === protocol.name && p.send) ) if (wallet) { walletId = Number(wallet.id) } await client.mutate({ mutation: getWalletProtocolUpsertMutation(protocol), variables: { ...(walletId ? { walletId } : { templateName }), enabled, ...encrypted } }) }, [client, encryptConfig]) return useMemo(() => ({ migrate, ready: ready && !loading }), [migrate, ready, loading]) } export function useUpdateKeyHash () { const [mutate] = useMutation(UPDATE_KEY_HASH) return useCallback(async (keyHash) => { await mutate({ variables: { keyHash } }) }, [mutate]) } function migrateConfig (protocol, config) { switch (protocol.name) { case 'LNBITS': return { url: config.url, apiKey: config.adminKey } case 'PHOENIXD': return { url: config.url, apiKey: config.primaryPassword } case 'BLINK': return { url: config.url, apiKey: config.apiKey, currency: config.currency } case 'LNC': return { pairingPhrase: config.pairingPhrase, localKey: config.localKey, remoteKey: config.remoteKey, serverHost: config.serverHost } case 'WEBLN': return {} case 'NWC': return { url: config.nwcUrl } default: return config } } function getWalletTemplateName (protocol) { switch (protocol.name) { case 'LNBITS': case 'PHOENIXD': case 'BLINK': case 'NWC': return protocol.name case 'LNC': return 'LND' case 'WEBLN': return 'ALBY' default: return null } }