Fix old invoice passed to QR code

This commit is contained in:
ekzyis 2024-11-26 03:29:28 +01:00
parent 7742257470
commit 7036804c67
4 changed files with 42 additions and 33 deletions

View File

@ -54,14 +54,14 @@ export const useInvoice = () => {
}, [cancelInvoice]) }, [cancelInvoice])
const retry = useCallback(async ({ id, hash, hmac }) => { const retry = useCallback(async ({ id, hash, hmac }) => {
// always cancel the previous invoice to make sure we can retry and it cannot be paid later
await cancel({ hash, hmac })
console.log('retrying invoice:', hash) console.log('retrying invoice:', hash)
const { data, error } = await retryPaidAction({ variables: { invoiceId: Number(id) } }) const { data, error } = await retryPaidAction({ variables: { invoiceId: Number(id) } })
if (error) throw error if (error) throw error
return data?.retryPaidAction?.invoice const newInvoice = data.retryPaidAction.invoice
console.log('new invoice:', newInvoice?.hash)
return newInvoice
}) })
return { cancel, retry, isInvoice } return { cancel, retry, isInvoice }

View File

@ -33,26 +33,28 @@ export function usePaidMutation (mutation,
const waitForPayment = useCallback(async (invoice, { alwaysShowQROnFailure = false, persistOnNavigate = false, waitFor }) => { const waitForPayment = useCallback(async (invoice, { alwaysShowQROnFailure = false, persistOnNavigate = false, waitFor }) => {
let walletError let walletError
let walletInvoice = invoice
const start = Date.now() const start = Date.now()
try { try {
return await waitForWalletPayment(invoice, waitFor) return await waitForWalletPayment(walletInvoice, waitFor)
} catch (err) { } catch (err) {
if (err instanceof WalletAggregateError || err instanceof NoWalletAvailableError) { if (err instanceof WalletAggregateError || err instanceof NoWalletAvailableError) {
walletError = err walletError = err
// wallet payment error handling always creates a new invoice to retry
if (err.newInvoice) walletInvoice = err.newInvoice
} }
if ( // bail if the payment took too long to prevent showing a QR code on an unrelated page
(!alwaysShowQROnFailure && Date.now() - start > 1000) || // (if alwaysShowQROnFailure is not set) or user canceled the invoice or it expired
err instanceof InvoiceCanceledError || const tooSlow = Date.now() - start > 1000
err instanceof InvoiceExpiredError) { const skipQr = (tooSlow && !alwaysShowQROnFailure) || err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError
// bail since qr code payment will also fail if (skipQr) {
// also bail if the payment took more than 1 second
// and cancel the invoice if it's not already canceled so it can be retried
invoiceHelper.cancel(invoice).catch(console.error)
throw err throw err
} }
} }
return await waitForQrPayment(invoice, walletError, { persistOnNavigate, waitFor })
return await waitForQrPayment(walletInvoice, walletError, { persistOnNavigate, waitFor })
}, [waitForWalletPayment, waitForQrPayment, invoiceHelper]) }, [waitForWalletPayment, waitForQrPayment, invoiceHelper])
const innerMutate = useCallback(async ({ const innerMutate = useCallback(async ({

View File

@ -22,16 +22,18 @@ export class WalletNotEnabledError extends Error {
} }
export class SenderError extends Error { export class SenderError extends Error {
constructor (name, hash) { constructor (name, invoice, message) {
super(`${name} failed to pay invoice: ${hash}`) super(`${name} failed to pay invoice ${invoice.hash}: ${message}`)
this.name = 'SenderError' this.name = 'SenderError'
this.invoice = invoice
} }
} }
export class WalletAggregateError extends AggregateError { export class WalletAggregateError extends AggregateError {
constructor (errors) { constructor (errors, newInvoice) {
super(errors) super(errors)
this.name = 'WalletAggregateError' this.name = 'WalletAggregateError'
this.newInvoice = newInvoice
} }
} }

View File

@ -1,5 +1,4 @@
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { decode as bolt11Decode } from 'bolt11'
import { useWallets } from '@/wallets' import { useWallets } from '@/wallets'
import walletDefs from '@/wallets/client' import walletDefs from '@/wallets/client'
import { formatSats } from '@/lib/format' import { formatSats } from '@/lib/format'
@ -44,25 +43,30 @@ export function useWalletPayment () {
let walletError = new WalletAggregateError([]) let walletError = new WalletAggregateError([])
let walletInvoice = invoice let walletInvoice = invoice
for (const wallet of walletsWithPayments) { for (const [index, wallet] of walletsWithPayments.entries()) {
const controller = invoiceController(walletInvoice.id, invoiceHelper.isInvoice) const controller = invoiceController(walletInvoice.id, invoiceHelper.isInvoice)
try { try {
return await new Promise((resolve, reject) => { return await new Promise((resolve, reject) => {
// can't await wallet payments since we might pay hold invoices and thus payments might not settle immediately. // 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. // that's why we separately check if we received the payment with the invoice controller.
wallet.sendPayment(walletInvoice.bolt11).catch(reject) wallet.sendPayment(walletInvoice).catch(reject)
controller.wait(waitFor) controller.wait(waitFor)
.then(resolve) .then(resolve)
.catch(reject) .catch(reject)
}) })
} catch (err) { } catch (err) {
// create a new invoice which cancels the previous one // cancel invoice to make sure it cannot be paid later.
// to make sure the old one cannot be paid later and we can retry. // we only need to do this if payment was attempted which is not the case if the wallet is not enabled.
// we don't need to do this if payment failed because wallet is not enabled const paymentAttempt = !(err instanceof WalletNotEnabledError)
// because we know that it didn't and won't try to pay. if (paymentAttempt) {
if (!(err instanceof WalletNotEnabledError)) { await invoiceHelper.cancel(walletInvoice)
// only create new invoice via retry if there is another wallet to try
const lastWallet = index === walletsWithPayments.length - 1
if (!lastWallet) {
walletInvoice = await invoiceHelper.retry(walletInvoice) walletInvoice = await invoiceHelper.retry(walletInvoice)
} }
}
// TODO: receiver fallbacks // TODO: receiver fallbacks
// //
@ -72,7 +76,7 @@ export function useWalletPayment () {
// try next wallet if the payment failed because of the wallet // try next wallet if the payment failed because of the wallet
// and not because it expired or was canceled // and not because it expired or was canceled
if (err instanceof WalletNotEnabledError || err instanceof SenderError) { if (err instanceof WalletNotEnabledError || err instanceof SenderError) {
walletError = new WalletAggregateError([...walletError.errors, err]) walletError = new WalletAggregateError([...walletError.errors, err], walletInvoice)
continue continue
} }
@ -93,7 +97,7 @@ export function useWalletPayment () {
// ignore errors from disabled wallets, only return payment errors // ignore errors from disabled wallets, only return payment errors
const paymentErrors = walletError.errors.filter(e => !(e instanceof WalletNotEnabledError)) const paymentErrors = walletError.errors.filter(e => !(e instanceof WalletNotEnabledError))
throw new WalletAggregateError(paymentErrors) throw new WalletAggregateError(paymentErrors, walletInvoice)
}, [walletsWithPayments, invoiceHelper]) }, [walletsWithPayments, invoiceHelper])
return waitForPayment return waitForPayment
@ -137,20 +141,21 @@ const invoiceController = (id, isInvoice) => {
} }
function sendPayment (wallet, logger) { function sendPayment (wallet, logger) {
return async (bolt11) => { return async (invoice) => {
if (!wallet.config.enabled) { if (!wallet.config.enabled) {
throw new WalletNotEnabledError(wallet.def.name) throw new WalletNotEnabledError(wallet.def.name)
} }
const decoded = bolt11Decode(bolt11) const { bolt11, satsRequested } = invoice
logger.info(`↗ sending payment: ${formatSats(decoded.satoshis)}`, { bolt11 })
logger.info(`↗ sending payment: ${formatSats(satsRequested)}`, { bolt11 })
try { try {
const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger }) const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger })
logger.ok(`↗ payment sent: ${formatSats(decoded.satoshis)}`, { bolt11, preimage }) logger.ok(`↗ payment sent: ${formatSats(satsRequested)}`, { bolt11, preimage })
} catch (err) { } catch (err) {
const message = err.message || err.toString?.() const message = err.message || err.toString?.()
logger.error(`payment failed: ${message}`, { bolt11 }) logger.error(`payment failed: ${message}`, { bolt11 })
throw new SenderError(wallet.def.name, decoded.tagsObject.payment_hash, message) throw new SenderError(wallet.def.name, invoice, message)
} }
} }
} }