* Wallet flow * Prepopulate fields of complementary protocol * Remove TODO about one mutation for save We need to save protocols in separate mutations so we can use the wallet id returned by the first protocol save for the following protocol saves and save them all to the same wallet. * Fix badges not updated on wallet delete * Fix useProtocol call * Fix lightning address save via prompt * Don't pass share as attribute to DOM * Fix useCallback dependency * Progress numbers as SVGs * Fix progress line margins * Remove unused saveWallet arguments * Update cache with settings response * Fix line does not connect with number 1 * Don't reuse page nav arrows in form nav * Fix missing SVG hover style * Fix missing space in wallet save log message * Reuse CSS from nav.module.css * align buttons and their icons/text * center form progress line * increase top padding of form on smaller screens * provide margin above button bar on settings form --------- Co-authored-by: k00b <k00b@stacker.news>
546 lines
17 KiB
JavaScript
546 lines
17 KiB
JavaScript
import {
|
|
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,
|
|
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,
|
|
DELETE_WALLET
|
|
} from '@/wallets/client/fragments'
|
|
import { gql, useApolloClient, useMutation, useQuery } from '@apollo/client'
|
|
import { useDecryption, useEncryption, useSetKey, useWalletLoggerFactory, useWalletsUpdatedAt, WalletStatus } from '@/wallets/client/hooks'
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import {
|
|
isEncryptedField, isTemplate, isWallet, protocolAvailable, protocolClientSchema, protocolLogName, 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(server2Client))
|
|
.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 useRefetchOnChange (refetch) {
|
|
const { me } = useMe()
|
|
const walletsUpdatedAt = useWalletsUpdatedAt()
|
|
|
|
useEffect(() => {
|
|
if (!me?.id) return
|
|
|
|
refetch()
|
|
}, [refetch, me?.id, walletsUpdatedAt])
|
|
}
|
|
|
|
export function useDecryptedWallet (wallet) {
|
|
const { decryptWallet, ready } = useWalletDecryption()
|
|
const [decryptedWallet, setDecryptedWallet] = useState(server2Client(wallet))
|
|
|
|
useEffect(() => {
|
|
if (!ready || !wallet) return
|
|
decryptWallet(wallet)
|
|
.then(server2Client)
|
|
.then(wallet => setDecryptedWallet(wallet))
|
|
.catch(err => {
|
|
console.error('failed to decrypt wallet:', err)
|
|
})
|
|
}, [decryptWallet, wallet, ready])
|
|
|
|
return decryptedWallet
|
|
}
|
|
|
|
function server2Client (wallet) {
|
|
// some protocols require a specific client environment
|
|
// e.g. WebLN requires a browser extension
|
|
function checkProtocolAvailability (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
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
function undoFieldAlias ({ id, ...wallet }) {
|
|
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 } }
|
|
}
|
|
|
|
return wallet ? undoFieldAlias(checkProtocolAvailability(wallet)) : wallet
|
|
}
|
|
|
|
export function useWalletProtocolUpsert () {
|
|
const client = useApolloClient()
|
|
const loggerFactory = useWalletLoggerFactory()
|
|
const { encryptConfig } = useEncryptConfig()
|
|
|
|
return useCallback(async (wallet, protocol, values) => {
|
|
const logger = loggerFactory(protocol)
|
|
const mutation = protocolUpsertMutation(protocol)
|
|
const name = `${protocolLogName(protocol)} ${protocol.send ? 'send' : 'receive'}`
|
|
|
|
logger.info(`saving ${name} ...`)
|
|
|
|
const encrypted = await encryptConfig(values, { protocol })
|
|
|
|
const variables = encrypted
|
|
if (isWallet(wallet)) {
|
|
variables.walletId = wallet.id
|
|
} else {
|
|
variables.templateName = wallet.name
|
|
}
|
|
|
|
let updatedWallet
|
|
try {
|
|
const { data } = await client.mutate({ mutation, variables })
|
|
logger.ok(`${name} saved`)
|
|
updatedWallet = Object.values(data)[0]
|
|
} catch (err) {
|
|
logger.error(err.message)
|
|
throw err
|
|
}
|
|
|
|
requestPersistentStorage()
|
|
|
|
return updatedWallet
|
|
}, [client, loggerFactory, encryptConfig])
|
|
}
|
|
|
|
export function useLightningAddressUpsert () {
|
|
const wallet = useMemo(() => ({ name: 'LN_ADDR', __typename: 'WalletTemplate' }), [])
|
|
const protocol = useMemo(() => ({ name: 'LN_ADDR', send: false, __typename: 'WalletProtocolTemplate' }), [])
|
|
const upsert = useWalletProtocolUpsert()
|
|
const testCreateInvoice = useTestCreateInvoice(protocol)
|
|
|
|
return useCallback(async (values) => {
|
|
// TODO(wallet-v2): parse domain from address input to use correct wallet template
|
|
await testCreateInvoice(values)
|
|
return await upsert(wallet, protocol, { ...values, enabled: true })
|
|
}, [testCreateInvoice, upsert, wallet, protocol])
|
|
}
|
|
|
|
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 protocolUpsertMutation (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 protocolTestMutation (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
|
|
}
|
|
}
|
|
|
|
export function useTestSendPayment (protocol) {
|
|
return useCallback(async (values) => {
|
|
return await protocolTestSendPayment(
|
|
protocol,
|
|
values,
|
|
{ signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS) }
|
|
)
|
|
}, [protocol])
|
|
}
|
|
|
|
export function useTestCreateInvoice (protocol) {
|
|
const mutation = protocolTestMutation(protocol)
|
|
const [testCreateInvoice] = useMutation(mutation)
|
|
|
|
return useCallback(async (values) => {
|
|
return await testCreateInvoice({ variables: values })
|
|
}, [testCreateInvoice])
|
|
}
|
|
|
|
export function useWalletDelete (wallet) {
|
|
const [mutate] = useMutation(DELETE_WALLET)
|
|
|
|
return useCallback(async () => {
|
|
await mutate({ variables: { id: wallet.id } })
|
|
}, [mutate, wallet.id])
|
|
}
|
|
|
|
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: protocolUpsertMutation(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
|
|
}
|
|
}
|