sender fallbacks

This commit is contained in:
ekzyis 2024-11-25 01:53:57 +01:00
parent 2b47bf527b
commit 5218a03b3a
5 changed files with 206 additions and 79 deletions

View File

@ -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>

View File

@ -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 = () => {

View File

@ -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])

View File

@ -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'
}
}

156
wallets/payment.js Normal file
View File

@ -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)
}
}
}