sender fallbacks
This commit is contained in:
parent
2b47bf527b
commit
5218a03b3a
|
@ -8,7 +8,7 @@ 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 { NoWalletAvailableError } from '@/wallets/errors'
|
||||
import ItemJob from './item-job'
|
||||
import Item from './item'
|
||||
import { CommentFlat } from './comment'
|
||||
|
@ -103,7 +103,8 @@ export default function Invoice ({
|
|||
|
||||
return (
|
||||
<>
|
||||
{walletError && !(walletError instanceof NoAttachedWalletError) &&
|
||||
{/* TODO: handle aggregated wallet errors */}
|
||||
{walletError && !(walletError instanceof NoWalletAvailableError) &&
|
||||
<div className='text-center fw-bold text-info mb-3' style={{ lineHeight: 1.25 }}>
|
||||
Paying from attached wallet failed:
|
||||
<code> {walletError.message}</code>
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import { useCallback } from 'react'
|
||||
import { gql, useApolloClient, useMutation } from '@apollo/client'
|
||||
import { useWallet } from '@/wallets/index'
|
||||
import { FAST_POLL_INTERVAL } 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()
|
||||
const [retryPaidAction] = useMutation(RETRY_PAID_ACTION)
|
||||
|
||||
const [cancelInvoice] = useMutation(gql`
|
||||
mutation cancelInvoice($hash: String!, $hmac: String!) {
|
||||
|
@ -53,73 +53,18 @@ export const useInvoice = () => {
|
|||
return inv
|
||||
}, [cancelInvoice])
|
||||
|
||||
return { 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 })
|
||||
|
||||
const invoiceController = (id, isInvoice) => {
|
||||
const controller = new AbortController()
|
||||
const signal = controller.signal
|
||||
controller.wait = async (waitFor = inv => inv?.actionState === 'PAID') => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const paid = await isInvoice({ id }, waitFor)
|
||||
if (paid) {
|
||||
resolve()
|
||||
clearInterval(interval)
|
||||
signal.removeEventListener('abort', abort)
|
||||
} else {
|
||||
console.info(`invoice #${id}: waiting for payment ...`)
|
||||
}
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
clearInterval(interval)
|
||||
signal.removeEventListener('abort', abort)
|
||||
}
|
||||
}, FAST_POLL_INTERVAL)
|
||||
console.log('retrying invoice:', hash)
|
||||
const { data, error } = await retryPaidAction({ variables: { invoiceId: Number(id) } })
|
||||
if (error) throw error
|
||||
|
||||
const abort = () => {
|
||||
console.info(`invoice #${id}: stopped waiting`)
|
||||
resolve()
|
||||
clearInterval(interval)
|
||||
signal.removeEventListener('abort', abort)
|
||||
}
|
||||
signal.addEventListener('abort', abort)
|
||||
})
|
||||
}
|
||||
return data?.retryPaidAction?.invoice
|
||||
})
|
||||
|
||||
controller.stop = () => controller.abort()
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
export const useWalletPayment = () => {
|
||||
const invoice = useInvoice()
|
||||
const wallet = useWallet()
|
||||
|
||||
const waitForWalletPayment = useCallback(async ({ id, bolt11 }, waitFor) => {
|
||||
if (!wallet) {
|
||||
throw new NoAttachedWalletError()
|
||||
}
|
||||
const controller = invoiceController(id, invoice.isInvoice)
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
// can't use await here since we might pay JIT invoices and sendPaymentAsync is not supported yet.
|
||||
// see https://www.webln.guide/building-lightning-apps/webln-reference/webln.sendpaymentasync
|
||||
wallet.sendPayment(bolt11).catch(reject)
|
||||
controller.wait(waitFor)
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('payment failed:', err)
|
||||
throw err
|
||||
} finally {
|
||||
controller.stop()
|
||||
}
|
||||
}, [wallet, invoice])
|
||||
|
||||
return waitForWalletPayment
|
||||
return { cancel, retry, isInvoice }
|
||||
}
|
||||
|
||||
export const useQrPayment = () => {
|
||||
|
|
|
@ -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 { useInvoice, useQrPayment } from './payment'
|
||||
import { InvoiceCanceledError, InvoiceExpiredError, NoWalletAvailableError, WalletAggregateError } from '@/wallets/errors'
|
||||
import { GET_PAID_ACTION } from '@/fragments/paidAction'
|
||||
import { useWalletPayment } from '@/wallets/payment'
|
||||
|
||||
/*
|
||||
this is just like useMutation with a few changes:
|
||||
|
@ -36,6 +37,10 @@ export function usePaidMutation (mutation,
|
|||
try {
|
||||
return await waitForWalletPayment(invoice, waitFor)
|
||||
} catch (err) {
|
||||
if (err instanceof WalletAggregateError || err instanceof NoWalletAvailableError) {
|
||||
walletError = err
|
||||
}
|
||||
|
||||
if (
|
||||
(!alwaysShowQROnFailure && Date.now() - start > 1000) ||
|
||||
err instanceof InvoiceCanceledError ||
|
||||
|
@ -46,7 +51,6 @@ export function usePaidMutation (mutation,
|
|||
invoiceHelper.cancel(invoice).catch(console.error)
|
||||
throw err
|
||||
}
|
||||
walletError = err
|
||||
}
|
||||
return await waitForQrPayment(invoice, walletError, { persistOnNavigate, waitFor })
|
||||
}, [waitForWalletPayment, waitForQrPayment, invoiceHelper])
|
||||
|
|
|
@ -7,16 +7,37 @@ 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 WalletNotEnabledError extends Error {
|
||||
constructor (name) {
|
||||
super(`wallet is not enabled: ${name}`)
|
||||
this.name = 'WalletNotEnabledError'
|
||||
}
|
||||
}
|
||||
|
||||
export class SenderError extends Error {
|
||||
constructor (name, hash) {
|
||||
super(`${name} failed to pay invoice: ${hash}`)
|
||||
this.name = 'WalletPaymentFailedError'
|
||||
}
|
||||
}
|
||||
|
||||
export class WalletAggregateError extends AggregateError {
|
||||
constructor (errors) {
|
||||
super(errors)
|
||||
this.name = 'WalletAggregateError'
|
||||
}
|
||||
}
|
||||
|
||||
export class NoWalletAvailableError extends Error {
|
||||
constructor () {
|
||||
super('no wallet for payments available')
|
||||
this.name = 'NoWalletAvailableError'
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,156 @@
|
|||
import { useCallback, useMemo } from 'react'
|
||||
import { decode as bolt11Decode } from 'bolt11'
|
||||
import { useWallets } from '@/wallets'
|
||||
import walletDefs from '@/wallets/client'
|
||||
import { formatSats } from '@/lib/format'
|
||||
import { useWalletLogger } from '@/components/wallet-logger'
|
||||
import { useInvoice } from '@/components/payment'
|
||||
import { FAST_POLL_INTERVAL } from '@/lib/constants'
|
||||
import { NoWalletAvailableError, SenderError, WalletAggregateError, WalletNotEnabledError } from '@/wallets/errors'
|
||||
|
||||
export function useWalletPayment () {
|
||||
const { wallets } = useWallets()
|
||||
const invoiceHelper = useInvoice()
|
||||
|
||||
// XXX calling hooks in a loop is against the rules of hooks
|
||||
//
|
||||
// we do this here anyway since we need the logger for each wallet.
|
||||
// we ensure hooks are always called in the same order by sorting the wallets by name.
|
||||
//
|
||||
// we don't use the return value of useWallets here because it is empty on first render
|
||||
// so using it would change the order of the hooks between renders.
|
||||
//
|
||||
// see https://react.dev/reference/rules/rules-of-hooks
|
||||
const loggers = walletDefs
|
||||
.sort((def1, def2) => def1.name.localeCompare(def2.name))
|
||||
.reduce((acc, def) => {
|
||||
return {
|
||||
...acc,
|
||||
[def.name]: useWalletLogger(def)?.logger
|
||||
}
|
||||
}, {})
|
||||
|
||||
const walletsWithPayments = useMemo(() => {
|
||||
return wallets.map(wallet => {
|
||||
const logger = loggers[wallet.def.name]
|
||||
return {
|
||||
...wallet,
|
||||
sendPayment: sendPayment(wallet, logger)
|
||||
}
|
||||
})
|
||||
}, [wallets, loggers])
|
||||
|
||||
const waitForPayment = useCallback(async (invoice, { waitFor }) => {
|
||||
let walletError = new WalletAggregateError([])
|
||||
let walletInvoice = invoice
|
||||
|
||||
for (const wallet of walletsWithPayments) {
|
||||
const controller = invoiceController(walletInvoice.id, invoiceHelper.isInvoice)
|
||||
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.
|
||||
wallet.sendPayment(walletInvoice.bolt11).catch(reject)
|
||||
controller.wait(waitFor)
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
})
|
||||
} catch (err) {
|
||||
// create a new invoice which cancels the previous one
|
||||
// to make sure the old one cannot be paid later and we can retry.
|
||||
// we don't need to do this if payment failed because wallet is not enabled
|
||||
// because we know that it didn't and won't try to pay.
|
||||
if (!(err instanceof WalletNotEnabledError)) {
|
||||
walletInvoice = await invoiceHelper.retry(walletInvoice)
|
||||
}
|
||||
|
||||
// TODO: receiver fallbacks
|
||||
//
|
||||
// if payment failed because of the receiver, we should use the same wallet again.
|
||||
// if (err instanceof ReceiverError) { ... }
|
||||
|
||||
// try next wallet if the payment failed because of the wallet
|
||||
// and not because it expired or was canceled
|
||||
if (err instanceof WalletNotEnabledError || err instanceof SenderError) {
|
||||
walletError = new WalletAggregateError([...walletError.errors, err])
|
||||
continue
|
||||
}
|
||||
|
||||
// payment failed not because of the sender or receiver wallet. bail out of attemping wallets.
|
||||
throw err
|
||||
} finally {
|
||||
controller.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// if we reach this line, no wallet payment succeeded
|
||||
|
||||
// if no wallet is enabled, throw a special error that caller can handle separately
|
||||
const noWalletAvailable = walletError.errors.all(e => e instanceof WalletNotEnabledError)
|
||||
if (noWalletAvailable) {
|
||||
throw new NoWalletAvailableError()
|
||||
}
|
||||
|
||||
// ignore errors from disabled wallets, only return payment errors
|
||||
const paymentErrors = walletError.errors.filter(e => !(e instanceof WalletNotEnabledError))
|
||||
throw new WalletAggregateError(paymentErrors)
|
||||
}, [walletsWithPayments, invoiceHelper])
|
||||
|
||||
return waitForPayment
|
||||
}
|
||||
|
||||
const invoiceController = (id, isInvoice) => {
|
||||
const controller = new AbortController()
|
||||
const signal = controller.signal
|
||||
controller.wait = async (waitFor = inv => inv?.actionState === 'PAID') => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const paid = await isInvoice({ id }, waitFor)
|
||||
if (paid) {
|
||||
resolve()
|
||||
clearInterval(interval)
|
||||
signal.removeEventListener('abort', abort)
|
||||
} else {
|
||||
console.info(`invoice #${id}: waiting for payment ...`)
|
||||
}
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
clearInterval(interval)
|
||||
signal.removeEventListener('abort', abort)
|
||||
}
|
||||
}, FAST_POLL_INTERVAL)
|
||||
|
||||
const abort = () => {
|
||||
console.info(`invoice #${id}: stopped waiting`)
|
||||
resolve()
|
||||
clearInterval(interval)
|
||||
signal.removeEventListener('abort', abort)
|
||||
}
|
||||
signal.addEventListener('abort', abort)
|
||||
})
|
||||
}
|
||||
|
||||
controller.stop = () => controller.abort()
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
function sendPayment (wallet, logger) {
|
||||
return async (bolt11) => {
|
||||
if (!wallet.config.enabled) {
|
||||
throw new WalletNotEnabledError(wallet.def.name)
|
||||
}
|
||||
|
||||
const decoded = bolt11Decode(bolt11)
|
||||
logger.info(`↗ sending payment: ${formatSats(decoded.satoshis)}`, { bolt11 })
|
||||
try {
|
||||
const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger })
|
||||
logger.ok(`↗ payment sent: ${formatSats(decoded.satoshis)}`, { bolt11, preimage })
|
||||
} catch (err) {
|
||||
const message = err.message || err.toString?.()
|
||||
logger.error(`payment failed: ${message}`, { bolt11 })
|
||||
throw new SenderError(wallet.def.name, decoded.tagsObject.payment_hash, message)
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue