stacker.news/wallets/payment.js

172 lines
6.0 KiB
JavaScript
Raw Normal View History

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'
import {
WalletsNotAvailableError, WalletSenderError, WalletAggregateError, WalletNotEnabledError,
WalletSendNotConfiguredError, WalletPaymentError, WalletError, WalletConfigurationError
} from '@/wallets/errors'
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
.filter(wallet => canSend(wallet))
.map(wallet => {
const logger = loggers[wallet.def.name]
return {
...wallet,
sendPayment: sendPayment(wallet, logger)
}
})
2024-11-25 00:53:57 +00:00
}, [wallets, loggers])
const waitForPayment = useCallback(async (invoice, { waitFor }) => {
let walletError = new WalletAggregateError([])
let walletInvoice = invoice
for (const [i, wallet] of walletsWithPayments.entries()) {
2024-11-26 09:05:00 +00:00
const controller = invoiceController(walletInvoice, invoiceHelper.isInvoice)
2024-11-25 00:53:57 +00:00
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) {
// 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.
const paymentAttempt = err instanceof WalletPaymentError
2024-11-26 02:29:28 +00:00
if (paymentAttempt) {
await invoiceHelper.cancel(walletInvoice)
// is there another wallet to try?
const lastAttempt = i === walletsWithPayments.length - 1
if (!lastAttempt) {
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
const isWalletError = err instanceof WalletError
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
// 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) {
throw new WalletsNotAvailableError()
2024-11-25 00:53:57 +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
}
2024-11-26 09:05:00 +00:00
const invoiceController = (inv, isInvoice) => {
2024-11-25 00:53:57 +00:00
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 {
2024-11-26 09:05:00 +00:00
const paid = await isInvoice(inv, waitFor)
2024-11-25 00:53:57 +00:00
if (paid) {
2024-11-26 09:05:00 +00:00
resolve(inv)
2024-11-25 00:53:57 +00:00
clearInterval(interval)
signal.removeEventListener('abort', abort)
} else {
2024-11-26 09:05:00 +00:00
console.info(`invoice #${inv.id}: waiting for payment ...`)
2024-11-25 00:53:57 +00:00
}
} catch (err) {
reject(err)
clearInterval(interval)
signal.removeEventListener('abort', abort)
}
}, FAST_POLL_INTERVAL)
const abort = () => {
2024-11-26 09:05:00 +00:00
console.info(`invoice #${inv.id}: stopped waiting`)
resolve(inv)
2024-11-25 00:53:57 +00:00
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)
}
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 })
throw new WalletSenderError(wallet.def.name, invoice, message)
2024-11-25 00:53:57 +00:00
}
}
}