Skip to content

Commit

Permalink
wip: sender fallbacks
Browse files Browse the repository at this point in the history
  • Loading branch information
ekzyis committed Nov 23, 2024
1 parent 8b8cb69 commit 9a2e6a7
Show file tree
Hide file tree
Showing 5 changed files with 76 additions and 35 deletions.
3 changes: 1 addition & 2 deletions components/invoice.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -107,7 +106,7 @@ export default function Invoice ({

return (
<>
{walletError && !(walletError instanceof NoAttachedWalletError) &&
{walletError &&
<div className='text-center fw-bold text-info mb-3' style={{ lineHeight: 1.25 }}>
Paying from attached wallet failed:
<code> {walletError.message}</code>
Expand Down
26 changes: 17 additions & 9 deletions components/payment.js
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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) {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand All @@ -137,7 +145,7 @@ export const useWalletPayment = () => {
} finally {
controller.stop()
}
}, [wallet, invoice])
}, [invoice])

return waitForWalletPayment
}
Expand Down
58 changes: 42 additions & 16 deletions components/use-paid-mutation.js
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand Down
21 changes: 14 additions & 7 deletions wallets/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
}
3 changes: 2 additions & 1 deletion wallets/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
Expand Down Expand Up @@ -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)
}
}
}

0 comments on commit 9a2e6a7

Please sign in to comment.