From 9a2e6a725e5f2b4591205ad6a3d6043e8715b529 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sat, 23 Nov 2024 05:30:45 +0100 Subject: [PATCH] wip: sender fallbacks --- components/invoice.js | 3 +- components/payment.js | 26 ++++++++++----- components/use-paid-mutation.js | 58 ++++++++++++++++++++++++--------- wallets/errors.js | 21 ++++++++---- wallets/index.js | 3 +- 5 files changed, 76 insertions(+), 35 deletions(-) diff --git a/components/invoice.js b/components/invoice.js index d74fff45a..97fe055a5 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -8,7 +8,6 @@ import Bolt11Info from './bolt11-info' import { useQuery } from '@apollo/client' import { INVOICE } from '@/fragments/wallet' import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants' -import { NoAttachedWalletError } from '@/wallets/errors' import ItemJob from './item-job' import Item from './item' import { CommentFlat } from './comment' @@ -107,7 +106,7 @@ export default function Invoice ({ return ( <> - {walletError && !(walletError instanceof NoAttachedWalletError) && + {walletError &&
Paying from attached wallet failed: {walletError.message} diff --git a/components/payment.js b/components/payment.js index be7c9c516..b7e5fa575 100644 --- a/components/payment.js +++ b/components/payment.js @@ -1,11 +1,11 @@ import { useCallback } from 'react' import { gql, useApolloClient, useMutation } from '@apollo/client' -import { useWallet } from '@/wallets/index' import { FAST_POLL_INTERVAL, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants' import { INVOICE } from '@/fragments/wallet' import Invoice from '@/components/invoice' import { useShowModal } from './modal' -import { InvoiceCanceledError, NoAttachedWalletError, InvoiceExpiredError } from '@/wallets/errors' +import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors' +import { RETRY_PAID_ACTION } from '@/fragments/paidAction' export const useInvoice = () => { const client = useApolloClient() @@ -29,6 +29,8 @@ export const useInvoice = () => { } `) + const [retryPaidAction] = useMutation(RETRY_PAID_ACTION) + const create = useCallback(async amount => { const { data, error } = await createInvoice({ variables: { amount, expireSecs: JIT_INVOICE_TIMEOUT_MS / 1000 } }) if (error) { @@ -73,7 +75,17 @@ export const useInvoice = () => { return inv }, [cancelInvoice]) - return { create, cancel, isInvoice } + 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) + const { data, error } = await retryPaidAction({ variables: { invoiceId: Number(id) } }) + if (error) throw error + return data?.retryPaidAction?.invoice + }) + + return { create, cancel, isInvoice, retry } } const invoiceController = (id, isInvoice) => { @@ -115,12 +127,8 @@ const invoiceController = (id, isInvoice) => { export const useWalletPayment = () => { const invoice = useInvoice() - const wallet = useWallet() - const waitForWalletPayment = useCallback(async ({ id, bolt11 }, waitFor) => { - if (!wallet) { - throw new NoAttachedWalletError() - } + const waitForWalletPayment = useCallback(async (wallet, { id, bolt11 }, waitFor) => { const controller = invoiceController(id, invoice.isInvoice) try { return await new Promise((resolve, reject) => { @@ -137,7 +145,7 @@ export const useWalletPayment = () => { } finally { controller.stop() } - }, [wallet, invoice]) + }, [invoice]) return waitForWalletPayment } diff --git a/components/use-paid-mutation.js b/components/use-paid-mutation.js index 765508b5f..3c6bff97e 100644 --- a/components/use-paid-mutation.js +++ b/components/use-paid-mutation.js @@ -1,8 +1,9 @@ import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client' import { useCallback, useState } from 'react' import { useInvoice, useQrPayment, useWalletPayment } from './payment' -import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors' +import { InvoiceCanceledError, WalletPaymentFailedError, WalletNotConfiguredError } from '@/wallets/errors' import { GET_PAID_ACTION } from '@/fragments/paidAction' +import { useWallets } from '@/wallets' /* this is just like useMutation with a few changes: @@ -23,33 +24,58 @@ export function usePaidMutation (mutation, const [getPaidAction] = useLazyQuery(GET_PAID_ACTION, { fetchPolicy: 'network-only' }) + + const { wallets } = useWallets() const waitForWalletPayment = useWalletPayment() const invoiceHelper = useInvoice() const waitForQrPayment = useQrPayment() + const client = useApolloClient() // innerResult is used to store/control the result of the mutation when innerMutate runs const [innerResult, setInnerResult] = useState(result) const waitForPayment = useCallback(async (invoice, { alwaysShowQROnFailure = false, persistOnNavigate = false, waitFor }) => { - let walletError const start = Date.now() - try { - return await waitForWalletPayment(invoice, waitFor) - } catch (err) { - if ( - (!alwaysShowQROnFailure && Date.now() - start > 1000) || - err instanceof InvoiceCanceledError || - err instanceof InvoiceExpiredError) { - // bail since qr code payment will also fail - // 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 + + let walletError = new AggregateError([]) + + for (const wallet of wallets) { + try { + return await waitForWalletPayment(wallet, invoice, waitFor) + } catch (err) { + // TODO: do we need this? we don't throw this anywhere yet + if (err instanceof WalletNotConfiguredError) { + // ignore wallets that are not configured + // and thus certainly didn't try to pay so no invoice retry needed + continue + } + + // create a new invoice which cancels the previous one + // to make sure the old one cannot be paid later and we can retry + invoice = await invoiceHelper.retry(invoice) + + // try next wallet if the payment failed because of the wallet + // and not because it expired or was canceled + if (err instanceof WalletPaymentFailedError) { + walletError = new AggregateError([...walletError.errors, err]) + continue + } + + // payment failed not because of the wallet. bail out of attemping wallets. + break } - walletError = err } + + // should we bail completely or show the QR code as a last resort for payment? + const tooSlow = Date.now() - start > 1000 + if (tooSlow && !alwaysShowQROnFailure) { + // TODO: how to handle aggregate errors in caller? + throw walletError + } + + // TODO: how to handle aggregate error in QR component? return await waitForQrPayment(invoice, walletError, { persistOnNavigate, waitFor }) - }, [waitForWalletPayment, waitForQrPayment, invoiceHelper]) + }, [wallets, waitForWalletPayment, waitForQrPayment, invoiceHelper]) const innerMutate = useCallback(async ({ onCompleted: innerOnCompleted, ...innerOptions diff --git a/wallets/errors.js b/wallets/errors.js index 5cfde92b5..2bcd3015f 100644 --- a/wallets/errors.js +++ b/wallets/errors.js @@ -7,16 +7,23 @@ export class InvoiceCanceledError extends Error { } } -export class NoAttachedWalletError extends Error { - constructor () { - super('no attached wallet found') - this.name = 'NoAttachedWalletError' - } -} - export class InvoiceExpiredError extends Error { constructor (hash) { super(`invoice expired: ${hash}`) this.name = 'InvoiceExpiredError' } } + +export class WalletPaymentFailedError extends Error { + constructor (name, hash) { + super(`${name} failed to pay invoice: ${hash}`) + this.name = 'WalletPaymentFailedError' + } +} + +export class WalletNotConfiguredError extends Error { + constructor (name) { + super(`wallet is not configured: ${name}`) + this.name = 'WalletNotConfiguredError' + } +} diff --git a/wallets/index.js b/wallets/index.js index ca30ca3c6..ae592c7fa 100644 --- a/wallets/index.js +++ b/wallets/index.js @@ -11,6 +11,7 @@ import walletDefs from '@/wallets/client' import { generateMutation } from './graphql' import { formatSats } from '@/lib/format' import useDarkMode from '@/components/dark-mode' +import { WalletPaymentFailedError } from './errors' const WalletsContext = createContext({ wallets: [] @@ -309,7 +310,7 @@ function sendPaymentWithLogger (wallet) { } catch (err) { const message = err.message || err.toString?.() logger.error(`payment failed: ${message}`, { bolt11 }) - throw err + throw new WalletPaymentFailedError(wallet.def.name, decoded.tagsObject.payment_hash, message) } } }