stacker.news/wallets/payment.js

170 lines
6.3 KiB
JavaScript

import { useCallback } from 'react'
import { useSendWallets } from '@/wallets'
import { formatSats } from '@/lib/format'
import useInvoice from '@/components/use-invoice'
import { FAST_POLL_INTERVAL, WALLET_SEND_PAYMENT_TIMEOUT_MS } from '@/lib/constants'
import {
WalletsNotAvailableError, WalletSenderError, WalletAggregateError, WalletPaymentAggregateError,
WalletNotEnabledError, WalletSendNotConfiguredError, WalletPaymentError, WalletError, WalletReceiverError
} from '@/wallets/errors'
import { canSend } from './common'
import { useWalletLoggerFactory } from './logger'
import { timeoutSignal, withTimeout } from '@/lib/time'
export function useWalletPayment () {
const wallets = useSendWallets()
const sendPayment = useSendPayment()
const loggerFactory = useWalletLoggerFactory()
const invoiceHelper = useInvoice()
return useCallback(async (invoice, { waitFor, updateOnFallback }) => {
let aggregateError = new WalletAggregateError([])
let latestInvoice = invoice
// throw a special error that caller can handle separately if no payment was attempted
if (wallets.length === 0) {
throw new WalletsNotAvailableError()
}
for (let i = 0; i < wallets.length; i++) {
const wallet = wallets[i]
const logger = loggerFactory(wallet)
const { bolt11 } = latestInvoice
const controller = invoiceController(latestInvoice, invoiceHelper.isInvoice)
const walletPromise = sendPayment(wallet, logger, latestInvoice)
const pollPromise = controller.wait(waitFor)
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.
walletPromise.catch(reject)
pollPromise.then(resolve).catch(reject)
})
} catch (err) {
let paymentError = err
const message = `payment failed: ${paymentError.reason ?? paymentError.message}`
if (!(paymentError instanceof WalletError)) {
// payment failed for some reason unrelated to wallets (ie invoice expired or was canceled).
// bail out of attempting wallets.
logger.error(message, { bolt11 })
throw paymentError
}
// at this point, paymentError is always a wallet error,
// we just need to distinguish between receiver and sender errors
try {
// we need to poll one more time to check for failed forwards since sender wallet errors
// can be caused by them which we want to handle as receiver errors, not sender errors.
await invoiceHelper.isInvoice(latestInvoice, waitFor)
} catch (err) {
if (err instanceof WalletError) {
paymentError = err
}
}
if (paymentError instanceof WalletReceiverError) {
// if payment failed because of the receiver, use the same wallet again
// and log this as info, not error
logger.info('failed to forward payment to receiver, retrying with new invoice', { bolt11 })
i -= 1
} else if (paymentError instanceof WalletPaymentError) {
// only log payment errors, not configuration errors
logger.error(message, { bolt11 })
}
if (paymentError instanceof WalletPaymentError) {
// if a payment was attempted, cancel invoice to make sure it cannot be paid later and create new invoice to retry.
await invoiceHelper.cancel(latestInvoice)
}
// only create a new invoice if we will try to pay with a wallet again
const retry = paymentError instanceof WalletReceiverError || i < wallets.length - 1
if (retry) {
latestInvoice = await invoiceHelper.retry(latestInvoice, { update: updateOnFallback })
}
aggregateError = new WalletAggregateError([aggregateError, paymentError], latestInvoice)
continue
} finally {
controller.stop()
}
}
// if we reach this line, no wallet payment succeeded
throw new WalletPaymentAggregateError([aggregateError], latestInvoice)
}, [wallets, invoiceHelper, sendPayment])
}
function invoiceController (inv, isInvoice) {
const controller = new AbortController()
const signal = controller.signal
controller.wait = async (waitFor = inv => inv?.actionState === 'PAID') => {
return await new Promise((resolve, reject) => {
let updatedInvoice, paid
const interval = setInterval(async () => {
try {
({ invoice: updatedInvoice, check: paid } = await isInvoice(inv, waitFor))
if (paid) {
resolve(updatedInvoice)
clearInterval(interval)
signal.removeEventListener('abort', abort)
} else {
console.info(`invoice #${inv.id}: waiting for payment ...`)
}
} catch (err) {
reject(err)
clearInterval(interval)
signal.removeEventListener('abort', abort)
}
}, FAST_POLL_INTERVAL)
const abort = () => {
console.info(`invoice #${inv.id}: stopped waiting`)
resolve(updatedInvoice)
clearInterval(interval)
signal.removeEventListener('abort', abort)
}
signal.addEventListener('abort', abort)
})
}
controller.stop = () => controller.abort()
return controller
}
function useSendPayment () {
return useCallback(async (wallet, logger, invoice) => {
if (!wallet.config.enabled) {
throw new WalletNotEnabledError(wallet.def.name)
}
if (!canSend(wallet)) {
throw new WalletSendNotConfiguredError(wallet.def.name)
}
const { bolt11, satsRequested } = invoice
logger.info(`↗ sending payment: ${formatSats(satsRequested)}`, { bolt11 })
try {
const preimage = await withTimeout(
wallet.def.sendPayment(bolt11, wallet.config, {
logger,
signal: timeoutSignal(WALLET_SEND_PAYMENT_TIMEOUT_MS)
}),
WALLET_SEND_PAYMENT_TIMEOUT_MS)
logger.ok(`↗ payment sent: ${formatSats(satsRequested)}`, { bolt11, preimage })
} catch (err) {
// we don't log the error here since we want to handle receiver errors separately
const message = err.message || err.toString?.()
throw new WalletSenderError(wallet.def.name, invoice, message)
}
}, [])
}