stacker.news/wallets/payment.js
ekzyis 0032e064b2
Automated retries (#1776)
* Poll failed invoices with visibility timeout

* Don't return intermediate failed invoices

* Don't retry too old invoices

* Retry invoices on client

* Only attempt payment 3 times

* Fix fallbacks during last retry

* Rename retry column to paymentAttempt

* Fix no index used

* Resolve TODOs

* Use expiring locks

* Better comments for constants

* Acquire lock during retry

* Use expiring lock in retry mutation

* Use now() instead of CURRENT_TIMESTAMP

* Cosmetic changes

* Immediately show failed post payments in notifications

* Update hasNewNotes

* Never retry on user cancel

For a consistent UX and less mental overhead, I decided to remove the exception for ITEM_CREATE where it would still retry in the background even though we want to show the payment failure immediately in notifications.

* Fix notifications without pending retries missing if no send wallets

If a stacker has no send wallets, they would miss notifications about failed payments because they would never get retried.

This commit fixes this by making the notifications query aware if the stacker has send wallets. This way, it can tell if a notification will be retried or not.

* Stop hiding userCancel in notifications

As mentioned in a previous commit, I want to show anything that will not be attempted anymore in notifications.

Before, I wanted to hide manually cancelled invoices but to not change experience unnecessarily and to decrease mental overhead, I changed my mind.

* Also consider invoice.cancelledAt in notifications

* Always retry failed payments, even without send wallets

* Fix notification indicator on retry timeout

* Set invoice.updated_at to date slightly in the future

* Use default job priority

* Stop retrying after one hour

* Remove special case for ITEM_CREATE

* Replace retryTimeout job with notification indicator query

* Fix sortTime

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2025-02-14 19:25:11 -06:00

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)
}
}, [])
}