2024-11-25 00:53:57 +00:00
|
|
|
import { useCallback, useMemo } from 'react'
|
|
|
|
import { useWallets } from '@/wallets'
|
|
|
|
import walletDefs from '@/wallets/client'
|
|
|
|
import { formatSats } from '@/lib/format'
|
|
|
|
import { useWalletLogger } from '@/components/wallet-logger'
|
|
|
|
import { useInvoice } from '@/components/payment'
|
|
|
|
import { FAST_POLL_INTERVAL } from '@/lib/constants'
|
2024-11-26 04:01:23 +00:00
|
|
|
import {
|
|
|
|
WalletsNotAvailableError, WalletSenderError, WalletAggregateError, WalletNotEnabledError,
|
|
|
|
WalletSendNotConfiguredError, WalletPaymentError, WalletError, WalletConfigurationError
|
|
|
|
} from '@/wallets/errors'
|
2024-11-26 03:27:44 +00:00
|
|
|
import { canSend } from './common'
|
2024-11-25 00:53:57 +00:00
|
|
|
|
|
|
|
export function useWalletPayment () {
|
|
|
|
const { wallets } = useWallets()
|
|
|
|
const invoiceHelper = useInvoice()
|
|
|
|
|
|
|
|
// XXX calling hooks in a loop is against the rules of hooks
|
|
|
|
//
|
|
|
|
// we do this here anyway since we need the logger for each wallet.
|
2024-11-26 02:33:31 +00:00
|
|
|
// hooks are always called in the same order since walletDefs does not change between renders.
|
2024-11-25 00:53:57 +00:00
|
|
|
//
|
|
|
|
// we don't use the return value of useWallets here because it is empty on first render
|
|
|
|
// so using it would change the order of the hooks between renders.
|
|
|
|
//
|
|
|
|
// see https://react.dev/reference/rules/rules-of-hooks
|
|
|
|
const loggers = walletDefs
|
|
|
|
.reduce((acc, def) => {
|
|
|
|
return {
|
|
|
|
...acc,
|
|
|
|
[def.name]: useWalletLogger(def)?.logger
|
|
|
|
}
|
|
|
|
}, {})
|
|
|
|
|
|
|
|
const walletsWithPayments = useMemo(() => {
|
|
|
|
return wallets.map(wallet => {
|
|
|
|
const logger = loggers[wallet.def.name]
|
|
|
|
return {
|
|
|
|
...wallet,
|
|
|
|
sendPayment: sendPayment(wallet, logger)
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}, [wallets, loggers])
|
|
|
|
|
|
|
|
const waitForPayment = useCallback(async (invoice, { waitFor }) => {
|
|
|
|
let walletError = new WalletAggregateError([])
|
|
|
|
let walletInvoice = invoice
|
|
|
|
|
2024-11-26 07:43:05 +00:00
|
|
|
for (const wallet of walletsWithPayments) {
|
2024-11-25 00:53:57 +00:00
|
|
|
const controller = invoiceController(walletInvoice.id, invoiceHelper.isInvoice)
|
|
|
|
try {
|
|
|
|
return await new Promise((resolve, reject) => {
|
|
|
|
// can't await wallet payments since we might pay hold invoices and thus payments might not settle immediately.
|
|
|
|
// that's why we separately check if we received the payment with the invoice controller.
|
2024-11-26 02:29:28 +00:00
|
|
|
wallet.sendPayment(walletInvoice).catch(reject)
|
2024-11-25 00:53:57 +00:00
|
|
|
controller.wait(waitFor)
|
|
|
|
.then(resolve)
|
|
|
|
.catch(reject)
|
|
|
|
})
|
|
|
|
} catch (err) {
|
2024-11-26 07:43:05 +00:00
|
|
|
// cancel invoice to make sure it cannot be paid later and create new invoice to retry.
|
2024-11-26 02:29:28 +00:00
|
|
|
// we only need to do this if payment was attempted which is not the case if the wallet is not enabled.
|
2024-11-26 04:01:23 +00:00
|
|
|
const paymentAttempt = err instanceof WalletPaymentError
|
2024-11-26 02:29:28 +00:00
|
|
|
if (paymentAttempt) {
|
|
|
|
await invoiceHelper.cancel(walletInvoice)
|
2024-11-26 07:43:05 +00:00
|
|
|
walletInvoice = await invoiceHelper.retry(walletInvoice)
|
2024-11-25 00:53:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// TODO: receiver fallbacks
|
|
|
|
//
|
|
|
|
// if payment failed because of the receiver, we should use the same wallet again.
|
|
|
|
// if (err instanceof ReceiverError) { ... }
|
|
|
|
|
|
|
|
// try next wallet if the payment failed because of the wallet
|
|
|
|
// and not because it expired or was canceled
|
2024-11-26 04:01:23 +00:00
|
|
|
const isWalletError = err instanceof WalletError
|
2024-11-26 03:27:44 +00:00
|
|
|
if (isWalletError) {
|
2024-11-26 02:29:28 +00:00
|
|
|
walletError = new WalletAggregateError([...walletError.errors, err], walletInvoice)
|
2024-11-25 00:53:57 +00:00
|
|
|
continue
|
|
|
|
}
|
|
|
|
|
|
|
|
// payment failed not because of the sender or receiver wallet. bail out of attemping wallets.
|
|
|
|
throw err
|
|
|
|
} finally {
|
|
|
|
controller.stop()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// if we reach this line, no wallet payment succeeded
|
|
|
|
|
2024-11-26 04:01:23 +00:00
|
|
|
// throw a special error that caller can handle separately if no payment was attempted
|
|
|
|
const noWalletAvailable = walletError.errors.every(e => e instanceof WalletConfigurationError)
|
2024-11-25 00:53:57 +00:00
|
|
|
if (noWalletAvailable) {
|
2024-11-26 04:01:23 +00:00
|
|
|
throw new WalletsNotAvailableError()
|
2024-11-25 00:53:57 +00:00
|
|
|
}
|
|
|
|
|
2024-11-26 04:01:23 +00:00
|
|
|
// only return payment errors
|
|
|
|
const paymentErrors = walletError.errors.filter(e => e instanceof WalletPaymentError)
|
2024-11-26 02:29:28 +00:00
|
|
|
throw new WalletAggregateError(paymentErrors, walletInvoice)
|
2024-11-25 00:53:57 +00:00
|
|
|
}, [walletsWithPayments, invoiceHelper])
|
|
|
|
|
|
|
|
return waitForPayment
|
|
|
|
}
|
|
|
|
|
|
|
|
const invoiceController = (id, isInvoice) => {
|
|
|
|
const controller = new AbortController()
|
|
|
|
const signal = controller.signal
|
|
|
|
controller.wait = async (waitFor = inv => inv?.actionState === 'PAID') => {
|
|
|
|
return await new Promise((resolve, reject) => {
|
|
|
|
const interval = setInterval(async () => {
|
|
|
|
try {
|
|
|
|
const paid = await isInvoice({ id }, waitFor)
|
|
|
|
if (paid) {
|
|
|
|
resolve()
|
|
|
|
clearInterval(interval)
|
|
|
|
signal.removeEventListener('abort', abort)
|
|
|
|
} else {
|
|
|
|
console.info(`invoice #${id}: waiting for payment ...`)
|
|
|
|
}
|
|
|
|
} catch (err) {
|
|
|
|
reject(err)
|
|
|
|
clearInterval(interval)
|
|
|
|
signal.removeEventListener('abort', abort)
|
|
|
|
}
|
|
|
|
}, FAST_POLL_INTERVAL)
|
|
|
|
|
|
|
|
const abort = () => {
|
|
|
|
console.info(`invoice #${id}: stopped waiting`)
|
|
|
|
resolve()
|
|
|
|
clearInterval(interval)
|
|
|
|
signal.removeEventListener('abort', abort)
|
|
|
|
}
|
|
|
|
signal.addEventListener('abort', abort)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
controller.stop = () => controller.abort()
|
|
|
|
|
|
|
|
return controller
|
|
|
|
}
|
|
|
|
|
|
|
|
function sendPayment (wallet, logger) {
|
2024-11-26 02:29:28 +00:00
|
|
|
return async (invoice) => {
|
2024-11-25 00:53:57 +00:00
|
|
|
if (!wallet.config.enabled) {
|
|
|
|
throw new WalletNotEnabledError(wallet.def.name)
|
|
|
|
}
|
|
|
|
|
2024-11-26 03:27:44 +00:00
|
|
|
if (!canSend(wallet)) {
|
|
|
|
throw new WalletSendNotConfiguredError(wallet.def.name)
|
|
|
|
}
|
|
|
|
|
2024-11-26 02:29:28 +00:00
|
|
|
const { bolt11, satsRequested } = invoice
|
|
|
|
|
|
|
|
logger.info(`↗ sending payment: ${formatSats(satsRequested)}`, { bolt11 })
|
2024-11-25 00:53:57 +00:00
|
|
|
try {
|
|
|
|
const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger })
|
2024-11-26 02:29:28 +00:00
|
|
|
logger.ok(`↗ payment sent: ${formatSats(satsRequested)}`, { bolt11, preimage })
|
2024-11-25 00:53:57 +00:00
|
|
|
} catch (err) {
|
|
|
|
const message = err.message || err.toString?.()
|
|
|
|
logger.error(`payment failed: ${message}`, { bolt11 })
|
2024-11-26 04:01:23 +00:00
|
|
|
throw new WalletSenderError(wallet.def.name, invoice, message)
|
2024-11-25 00:53:57 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|