Merge pull request #1642 from stackernews/sender-fallbacks
Sender fallbacks
This commit is contained in:
commit
8ce2e46519
|
@ -304,28 +304,43 @@ export async function retryPaidAction (actionType, args, incomingContext) {
|
|||
throw new Error(`retryPaidAction - must be logged in ${actionType}`)
|
||||
}
|
||||
|
||||
if (!action.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC)) {
|
||||
throw new Error(`retryPaidAction - action does not support optimism ${actionType}`)
|
||||
}
|
||||
|
||||
if (!action.retry) {
|
||||
throw new Error(`retryPaidAction - action does not support retrying ${actionType}`)
|
||||
}
|
||||
|
||||
if (!failedInvoice) {
|
||||
throw new Error(`retryPaidAction - missing invoice ${actionType}`)
|
||||
}
|
||||
|
||||
const { msatsRequested, actionId, actionArgs } = failedInvoice
|
||||
const { msatsRequested, actionId, actionArgs, actionOptimistic } = failedInvoice
|
||||
const retryContext = {
|
||||
...incomingContext,
|
||||
optimistic: true,
|
||||
optimistic: actionOptimistic,
|
||||
me: await models.user.findUnique({ where: { id: me.id } }),
|
||||
cost: BigInt(msatsRequested),
|
||||
actionId
|
||||
}
|
||||
|
||||
const invoiceArgs = await createSNInvoice(actionType, actionArgs, retryContext)
|
||||
let invoiceArgs
|
||||
const invoiceForward = await models.invoiceForward.findUnique({
|
||||
where: { invoiceId: failedInvoice.id },
|
||||
include: {
|
||||
wallet: true,
|
||||
invoice: true,
|
||||
withdrawl: true
|
||||
}
|
||||
})
|
||||
// TODO: receiver fallbacks
|
||||
// use next receiver wallet if forward failed (we currently immediately fallback to SN)
|
||||
const failedForward = invoiceForward?.withdrawl && invoiceForward.withdrawl.actionState !== 'CONFIRMED'
|
||||
if (invoiceForward && !failedForward) {
|
||||
const { userId } = invoiceForward.wallet
|
||||
const { invoice: bolt11, wrappedInvoice: wrappedBolt11, wallet, maxFee } = await createWrappedInvoice(userId, {
|
||||
msats: failedInvoice.msatsRequested,
|
||||
feePercent: await action.getSybilFeePercent?.(actionArgs, retryContext),
|
||||
description: await action.describe?.(actionArgs, retryContext),
|
||||
expiry: INVOICE_EXPIRE_SECS
|
||||
}, retryContext)
|
||||
invoiceArgs = { bolt11, wrappedBolt11, wallet, maxFee }
|
||||
} else {
|
||||
invoiceArgs = await createSNInvoice(actionType, actionArgs, retryContext)
|
||||
}
|
||||
|
||||
return await models.$transaction(async tx => {
|
||||
const context = { ...retryContext, tx, invoiceArgs }
|
||||
|
@ -345,9 +360,9 @@ export async function retryPaidAction (actionType, args, incomingContext) {
|
|||
const invoice = await createDbInvoice(actionType, actionArgs, context)
|
||||
|
||||
return {
|
||||
result: await action.retry({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context),
|
||||
result: await action.retry?.({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context),
|
||||
invoice,
|
||||
paymentMethod: 'OPTIMISTIC'
|
||||
paymentMethod: actionOptimistic ? 'OPTIMISTIC' : 'PESSIMISTIC'
|
||||
}
|
||||
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
|
||||
}
|
||||
|
|
|
@ -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 { WalletConfigurationError, WalletPaymentAggregateError } from '@/wallets/errors'
|
||||
import ItemJob from './item-job'
|
||||
import Item from './item'
|
||||
import { CommentFlat } from './comment'
|
||||
|
@ -103,11 +103,7 @@ export default function Invoice ({
|
|||
|
||||
return (
|
||||
<>
|
||||
{walletError && !(walletError instanceof NoAttachedWalletError) &&
|
||||
<div className='text-center fw-bold text-info mb-3' style={{ lineHeight: 1.25 }}>
|
||||
Paying from attached wallet failed:
|
||||
<code> {walletError.message}</code>
|
||||
</div>}
|
||||
<WalletError error={walletError} />
|
||||
<Qr
|
||||
value={invoice.bolt11}
|
||||
description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
|
||||
|
@ -205,3 +201,23 @@ function ActionInfo ({ invoice }) {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WalletError ({ error }) {
|
||||
if (!error || error instanceof WalletConfigurationError) return null
|
||||
|
||||
if (!(error instanceof WalletPaymentAggregateError)) {
|
||||
console.error('unexpected wallet error:', error)
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='text-center fw-bold text-info mb-3' style={{ lineHeight: 1.25 }}>
|
||||
<div className='text-info mb-2'>Paying from attached wallets failed:</div>
|
||||
{error.errors.map((e, i) => (
|
||||
<div key={i}>
|
||||
<code>{e.wallet}: {e.reason || e.message}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ import { usePaidMutation } from './use-paid-mutation'
|
|||
import { ACT_MUTATION } from '@/fragments/paidAction'
|
||||
import { meAnonSats } from '@/lib/apollo'
|
||||
import { BoostItemInput } from './adv-post-form'
|
||||
import { useWallet } from '@/wallets/index'
|
||||
import { useSendWallets } from '@/wallets/index'
|
||||
|
||||
const defaultTips = [100, 1000, 10_000, 100_000]
|
||||
|
||||
|
@ -89,7 +89,7 @@ function BoostForm ({ step, onSubmit, children, item, oValue, inputRef, act = 'B
|
|||
export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) {
|
||||
const inputRef = useRef(null)
|
||||
const { me } = useMe()
|
||||
const wallet = useWallet()
|
||||
const wallets = useSendWallets()
|
||||
const [oValue, setOValue] = useState()
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -117,7 +117,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
|
|||
if (!me) setItemMeAnonSats({ id: item.id, amount })
|
||||
}
|
||||
|
||||
const closeImmediately = !!wallet || me?.privates?.sats > Number(amount)
|
||||
const closeImmediately = wallets.length > 0 || me?.privates?.sats > Number(amount)
|
||||
if (closeImmediately) {
|
||||
onPaid()
|
||||
}
|
||||
|
@ -143,7 +143,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
|
|||
})
|
||||
if (error) throw error
|
||||
addCustomTip(Number(amount))
|
||||
}, [me, actor, !!wallet, act, item.id, onClose, abortSignal, strike])
|
||||
}, [me, actor, wallets.length, act, item.id, onClose, abortSignal, strike])
|
||||
|
||||
return act === 'BOOST'
|
||||
? <BoostForm step={step} onSubmit={onSubmit} item={item} inputRef={inputRef} act={act}>{children}</BoostForm>
|
||||
|
@ -260,7 +260,7 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
|
|||
}
|
||||
|
||||
export function useZap () {
|
||||
const wallet = useWallet()
|
||||
const wallets = useSendWallets()
|
||||
const act = useAct()
|
||||
const strike = useLightning()
|
||||
const toaster = useToast()
|
||||
|
@ -278,7 +278,7 @@ export function useZap () {
|
|||
await abortSignal.pause({ me, amount: sats })
|
||||
strike()
|
||||
// batch zaps if wallet is enabled or using fee credits so they can be executed serially in a single request
|
||||
const { error } = await act({ variables, optimisticResponse, context: { batch: !!wallet || me?.privates?.sats > sats } })
|
||||
const { error } = await act({ variables, optimisticResponse, context: { batch: wallets.length > 0 || me?.privates?.sats > sats } })
|
||||
if (error) throw error
|
||||
} catch (error) {
|
||||
if (error instanceof ActCanceledError) {
|
||||
|
@ -288,7 +288,7 @@ export function useZap () {
|
|||
const reason = error?.message || error?.toString?.()
|
||||
toaster.danger(reason)
|
||||
}
|
||||
}, [act, toaster, strike, !!wallet])
|
||||
}, [act, toaster, strike, wallets.length])
|
||||
}
|
||||
|
||||
export class ActCanceledError extends Error {
|
||||
|
|
|
@ -370,6 +370,29 @@ function useActRetry ({ invoice }) {
|
|||
invoice.item.root?.bounty === invoice.satsRequested && invoice.item.root?.mine
|
||||
? payBountyCacheMods
|
||||
: {}
|
||||
|
||||
const update = (cache, { data }) => {
|
||||
const response = Object.values(data)[0]
|
||||
if (!response?.invoice) return
|
||||
cache.modify({
|
||||
id: `ItemAct:${invoice.itemAct?.id}`,
|
||||
fields: {
|
||||
// this is a bit of a hack just to update the reference to the new invoice
|
||||
invoice: () => cache.writeFragment({
|
||||
id: `Invoice:${response.invoice.id}`,
|
||||
fragment: gql`
|
||||
fragment _ on Invoice {
|
||||
bolt11
|
||||
}
|
||||
`,
|
||||
data: { bolt11: response.invoice.bolt11 }
|
||||
})
|
||||
}
|
||||
})
|
||||
paidActionCacheMods?.update?.(cache, { data })
|
||||
bountyCacheMods?.update?.(cache, { data })
|
||||
}
|
||||
|
||||
return useAct({
|
||||
query: RETRY_PAID_ACTION,
|
||||
onPayError: (e, cache, { data }) => {
|
||||
|
@ -380,27 +403,8 @@ function useActRetry ({ invoice }) {
|
|||
paidActionCacheMods?.onPaid?.(cache, { data })
|
||||
bountyCacheMods?.onPaid?.(cache, { data })
|
||||
},
|
||||
update: (cache, { data }) => {
|
||||
const response = Object.values(data)[0]
|
||||
if (!response?.invoice) return
|
||||
cache.modify({
|
||||
id: `ItemAct:${invoice.itemAct?.id}`,
|
||||
fields: {
|
||||
// this is a bit of a hack just to update the reference to the new invoice
|
||||
invoice: () => cache.writeFragment({
|
||||
id: `Invoice:${response.invoice.id}`,
|
||||
fragment: gql`
|
||||
fragment _ on Invoice {
|
||||
bolt11
|
||||
}
|
||||
`,
|
||||
data: { bolt11: response.invoice.bolt11 }
|
||||
})
|
||||
}
|
||||
})
|
||||
paidActionCacheMods?.update?.(cache, { data })
|
||||
bountyCacheMods?.update?.(cache, { data })
|
||||
}
|
||||
update,
|
||||
updateOnFallback: update
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -1,22 +1,16 @@
|
|||
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 { useApolloClient, useMutation } from '@apollo/client'
|
||||
import { CANCEL_INVOICE, 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!) {
|
||||
cancelInvoice(hash: $hash, hmac: $hmac) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`)
|
||||
const [cancelInvoice] = useMutation(CANCEL_INVOICE)
|
||||
|
||||
const isInvoice = useCallback(async ({ id }, that) => {
|
||||
const { data, error } = await client.query({ query: INVOICE, fetchPolicy: 'network-only', variables: { id } })
|
||||
|
@ -24,15 +18,15 @@ export const useInvoice = () => {
|
|||
throw error
|
||||
}
|
||||
|
||||
const { hash, cancelled, cancelledAt, actionError, actionState, expiresAt } = data.invoice
|
||||
const { cancelled, cancelledAt, actionError, actionState, expiresAt } = data.invoice
|
||||
|
||||
const expired = cancelledAt && new Date(expiresAt) < new Date(cancelledAt)
|
||||
if (expired) {
|
||||
throw new InvoiceExpiredError(hash)
|
||||
throw new InvoiceExpiredError(data.invoice)
|
||||
}
|
||||
|
||||
if (cancelled || actionError) {
|
||||
throw new InvoiceCanceledError(hash, actionError)
|
||||
throw new InvoiceCanceledError(data.invoice, actionError)
|
||||
}
|
||||
|
||||
// write to cache if paid
|
||||
|
@ -40,7 +34,7 @@ export const useInvoice = () => {
|
|||
client.writeQuery({ query: INVOICE, variables: { id }, data: { invoice: data.invoice } })
|
||||
}
|
||||
|
||||
return that(data.invoice)
|
||||
return { invoice: data.invoice, check: that(data.invoice) }
|
||||
}, [client])
|
||||
|
||||
const cancel = useCallback(async ({ hash, hmac }) => {
|
||||
|
@ -49,77 +43,22 @@ export const useInvoice = () => {
|
|||
}
|
||||
|
||||
console.log('canceling invoice:', hash)
|
||||
const inv = await cancelInvoice({ variables: { hash, hmac } })
|
||||
return inv
|
||||
const { data } = await cancelInvoice({ variables: { hash, hmac } })
|
||||
return data.cancelInvoice
|
||||
}, [cancelInvoice])
|
||||
|
||||
return { cancel, isInvoice }
|
||||
}
|
||||
const retry = useCallback(async ({ id, hash, hmac }, { update }) => {
|
||||
console.log('retrying invoice:', hash)
|
||||
const { data, error } = await retryPaidAction({ variables: { invoiceId: Number(id) }, update })
|
||||
if (error) throw error
|
||||
|
||||
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 newInvoice = data.retryPaidAction.invoice
|
||||
console.log('new invoice:', newInvoice?.hash)
|
||||
|
||||
const abort = () => {
|
||||
console.info(`invoice #${id}: stopped waiting`)
|
||||
resolve()
|
||||
clearInterval(interval)
|
||||
signal.removeEventListener('abort', abort)
|
||||
}
|
||||
signal.addEventListener('abort', abort)
|
||||
})
|
||||
}
|
||||
return newInvoice
|
||||
}, [retryPaidAction])
|
||||
|
||||
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 = () => {
|
||||
|
@ -138,10 +77,10 @@ export const useQrPayment = () => {
|
|||
let paid
|
||||
const cancelAndReject = async (onClose) => {
|
||||
if (!paid && cancelOnClose) {
|
||||
await invoice.cancel(inv).catch(console.error)
|
||||
reject(new InvoiceCanceledError(inv?.hash))
|
||||
const updatedInv = await invoice.cancel(inv).catch(console.error)
|
||||
reject(new InvoiceCanceledError(updatedInv))
|
||||
}
|
||||
resolve()
|
||||
resolve(inv)
|
||||
}
|
||||
showModal(onClose =>
|
||||
<Invoice
|
||||
|
@ -152,9 +91,9 @@ export const useQrPayment = () => {
|
|||
successVerb='received'
|
||||
walletError={walletError}
|
||||
waitFor={waitFor}
|
||||
onExpired={inv => reject(new InvoiceExpiredError(inv?.hash))}
|
||||
onCanceled={inv => { onClose(); reject(new InvoiceCanceledError(inv?.hash, inv?.actionError)) }}
|
||||
onPayment={() => { paid = true; onClose(); resolve() }}
|
||||
onExpired={inv => reject(new InvoiceExpiredError(inv))}
|
||||
onCanceled={inv => { onClose(); reject(new InvoiceCanceledError(inv, inv?.actionError)) }}
|
||||
onPayment={(inv) => { paid = true; onClose(); resolve(inv) }}
|
||||
poll
|
||||
/>,
|
||||
{ keepOpen, persistOnNavigate, onClose: cancelAndReject })
|
||||
|
|
|
@ -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, WalletError, WalletPaymentError } from '@/wallets/errors'
|
||||
import { GET_PAID_ACTION } from '@/fragments/paidAction'
|
||||
import { useWalletPayment } from '@/wallets/payment'
|
||||
|
||||
/*
|
||||
this is just like useMutation with a few changes:
|
||||
|
@ -30,25 +31,41 @@ export function usePaidMutation (mutation,
|
|||
// 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 }) => {
|
||||
const waitForPayment = useCallback(async (invoice, { alwaysShowQROnFailure = false, persistOnNavigate = false, waitFor, updateOnFallback }) => {
|
||||
let walletError
|
||||
let walletInvoice = invoice
|
||||
const start = Date.now()
|
||||
|
||||
try {
|
||||
return await waitForWalletPayment(invoice, waitFor)
|
||||
return await waitForWalletPayment(walletInvoice, { waitFor, updateOnFallback })
|
||||
} 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)
|
||||
walletError = null
|
||||
if (err instanceof WalletError) {
|
||||
walletError = err
|
||||
// get the last invoice that was attempted but failed and was canceled
|
||||
if (err.invoice) walletInvoice = err.invoice
|
||||
}
|
||||
|
||||
const invoiceError = err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError
|
||||
if (!invoiceError && !walletError) {
|
||||
// unexpected error, rethrow
|
||||
throw err
|
||||
}
|
||||
|
||||
// bail if the payment took too long to prevent showing a QR code on an unrelated page
|
||||
// (if alwaysShowQROnFailure is not set) or user canceled the invoice or it expired
|
||||
const tooSlow = Date.now() - start > 1000
|
||||
const skipQr = (tooSlow && !alwaysShowQROnFailure) || invoiceError
|
||||
if (skipQr) {
|
||||
throw err
|
||||
}
|
||||
walletError = err
|
||||
}
|
||||
return await waitForQrPayment(invoice, walletError, { persistOnNavigate, waitFor })
|
||||
|
||||
const paymentAttempted = walletError instanceof WalletPaymentError
|
||||
if (paymentAttempted) {
|
||||
walletInvoice = await invoiceHelper.retry(walletInvoice, { update: updateOnFallback })
|
||||
}
|
||||
return await waitForQrPayment(walletInvoice, walletError, { persistOnNavigate, waitFor })
|
||||
}, [waitForWalletPayment, waitForQrPayment, invoiceHelper])
|
||||
|
||||
const innerMutate = useCallback(async ({
|
||||
|
@ -60,7 +77,7 @@ export function usePaidMutation (mutation,
|
|||
// use the most inner callbacks/options if they exist
|
||||
const {
|
||||
onPaid, onPayError, forceWaitForPayment, persistOnNavigate,
|
||||
update, waitFor = inv => inv?.actionState === 'PAID'
|
||||
update, waitFor = inv => inv?.actionState === 'PAID', updateOnFallback
|
||||
} = { ...options, ...innerOptions }
|
||||
const ourOnCompleted = innerOnCompleted || onCompleted
|
||||
|
||||
|
@ -69,7 +86,7 @@ export function usePaidMutation (mutation,
|
|||
throw new Error('usePaidMutation: exactly one mutation at a time is supported')
|
||||
}
|
||||
const response = Object.values(data)[0]
|
||||
const invoice = response?.invoice
|
||||
let invoice = response?.invoice
|
||||
|
||||
// if the mutation returns an invoice, pay it
|
||||
if (invoice) {
|
||||
|
@ -81,15 +98,28 @@ export function usePaidMutation (mutation,
|
|||
error: e instanceof InvoiceCanceledError && e.actionError ? e : undefined
|
||||
})
|
||||
|
||||
const mergeData = obj => ({
|
||||
[Object.keys(data)[0]]: {
|
||||
...data?.[Object.keys(data)[0]],
|
||||
...obj
|
||||
}
|
||||
})
|
||||
|
||||
// should we wait for the invoice to be paid?
|
||||
if (response?.paymentMethod === 'OPTIMISTIC' && !forceWaitForPayment) {
|
||||
// onCompleted is called before the invoice is paid for optimistic updates
|
||||
ourOnCompleted?.(data)
|
||||
// don't wait to pay the invoice
|
||||
waitForPayment(invoice, { persistOnNavigate, waitFor }).then(() => {
|
||||
waitForPayment(invoice, { persistOnNavigate, waitFor, updateOnFallback }).then((invoice) => {
|
||||
// invoice might have been retried during payment
|
||||
data = mergeData({ invoice })
|
||||
onPaid?.(client.cache, { data })
|
||||
}).catch(e => {
|
||||
console.error('usePaidMutation: failed to pay invoice', e)
|
||||
if (e.invoice) {
|
||||
// update the failed invoice for the Apollo cache update
|
||||
data = mergeData({ invoice: e.invoice })
|
||||
}
|
||||
// onPayError is called after the invoice fails to pay
|
||||
// useful for updating invoiceActionState to FAILED
|
||||
onPayError?.(e, client.cache, { data })
|
||||
|
@ -99,18 +129,14 @@ export function usePaidMutation (mutation,
|
|||
// the action is pessimistic
|
||||
try {
|
||||
// wait for the invoice to be paid
|
||||
await waitForPayment(invoice, { alwaysShowQROnFailure: true, persistOnNavigate, waitFor })
|
||||
// returns the invoice that was paid since it might have been updated via retries
|
||||
invoice = await waitForPayment(invoice, { alwaysShowQROnFailure: true, persistOnNavigate, waitFor, updateOnFallback })
|
||||
if (!response.result) {
|
||||
// if the mutation didn't return any data, ie pessimistic, we need to fetch it
|
||||
const { data: { paidAction } } = await getPaidAction({ variables: { invoiceId: parseInt(invoice.id) } })
|
||||
// create new data object
|
||||
// ( hmac is only returned on invoice creation so we need to add it back to the data )
|
||||
data = {
|
||||
[Object.keys(data)[0]]: {
|
||||
...paidAction,
|
||||
invoice: { ...paidAction.invoice, hmac: invoice.hmac }
|
||||
}
|
||||
}
|
||||
data = mergeData({ ...paidAction, invoice: { ...paidAction.invoice, hmac: invoice.hmac } })
|
||||
// we need to run update functions on mutations now that we have the data
|
||||
update?.(client.cache, { data })
|
||||
}
|
||||
|
|
|
@ -5,13 +5,11 @@ import { Button } from 'react-bootstrap'
|
|||
import { useToast } from './toast'
|
||||
import { useShowModal } from './modal'
|
||||
import { WALLET_LOGS } from '@/fragments/wallet'
|
||||
import { getWalletByType } from '@/wallets/common'
|
||||
import { getWalletByType, walletTag } from '@/wallets/common'
|
||||
import { gql, useLazyQuery, useMutation } from '@apollo/client'
|
||||
import { useMe } from './me'
|
||||
import useIndexedDB, { getDbName } from './use-indexeddb'
|
||||
import { SSR } from '@/lib/constants'
|
||||
import { decode as bolt11Decode } from 'bolt11'
|
||||
import { formatMsats } from '@/lib/format'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
export function WalletLogs ({ wallet, embedded }) {
|
||||
|
@ -61,7 +59,7 @@ export function WalletLogs ({ wallet, embedded }) {
|
|||
}
|
||||
|
||||
function DeleteWalletLogsObstacle ({ wallet, setLogs, onClose }) {
|
||||
const { deleteLogs } = useWalletLogger(wallet, setLogs)
|
||||
const { deleteLogs } = useWalletLogManager(setLogs)
|
||||
const toaster = useToast()
|
||||
|
||||
const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?`
|
||||
|
@ -110,11 +108,11 @@ function useWalletLogDB () {
|
|||
return { add, getPage, clear, error, notSupported }
|
||||
}
|
||||
|
||||
export function useWalletLogger (wallet, setLogs) {
|
||||
export function useWalletLogManager (setLogs) {
|
||||
const { add, clear, notSupported } = useWalletLogDB()
|
||||
|
||||
const appendLog = useCallback(async (wallet, level, message, context) => {
|
||||
const log = { wallet: tag(wallet), level, message, ts: +new Date(), context }
|
||||
const log = { wallet: walletTag(wallet.def), level, message, ts: +new Date(), context }
|
||||
try {
|
||||
if (notSupported) {
|
||||
console.log('cannot persist wallet log: indexeddb not supported')
|
||||
|
@ -146,56 +144,20 @@ export function useWalletLogger (wallet, setLogs) {
|
|||
}
|
||||
if (!wallet || wallet.sendPayment) {
|
||||
try {
|
||||
const walletTag = wallet ? tag(wallet) : null
|
||||
const tag = wallet ? walletTag(wallet.def) : null
|
||||
if (notSupported) {
|
||||
console.log('cannot clear wallet logs: indexeddb not supported')
|
||||
} else {
|
||||
await clear('wallet_ts', walletTag ? window.IDBKeyRange.bound([walletTag, 0], [walletTag, Infinity]) : null)
|
||||
await clear('wallet_ts', tag ? window.IDBKeyRange.bound([tag, 0], [tag, Infinity]) : null)
|
||||
}
|
||||
setLogs?.(logs => logs.filter(l => wallet ? l.wallet !== tag(wallet) : false))
|
||||
setLogs?.(logs => logs.filter(l => wallet ? l.wallet !== tag : false))
|
||||
} catch (e) {
|
||||
console.error('failed to delete logs', e)
|
||||
}
|
||||
}
|
||||
}, [clear, deleteServerWalletLogs, setLogs, notSupported])
|
||||
|
||||
const log = useCallback(level => (message, context = {}) => {
|
||||
if (!wallet) {
|
||||
// console.error('cannot log: no wallet set')
|
||||
return
|
||||
}
|
||||
|
||||
if (context?.bolt11) {
|
||||
// automatically populate context from bolt11 to avoid duplicating this code
|
||||
const decoded = bolt11Decode(context.bolt11)
|
||||
context = {
|
||||
...context,
|
||||
amount: formatMsats(decoded.millisatoshis),
|
||||
payment_hash: decoded.tagsObject.payment_hash,
|
||||
description: decoded.tagsObject.description,
|
||||
created_at: new Date(decoded.timestamp * 1000).toISOString(),
|
||||
expires_at: new Date(decoded.timeExpireDate * 1000).toISOString(),
|
||||
// payments should affect wallet status
|
||||
status: true
|
||||
}
|
||||
}
|
||||
context.send = true
|
||||
|
||||
appendLog(wallet, level, message, context)
|
||||
console[level !== 'error' ? 'info' : 'error'](`[${tag(wallet)}]`, message)
|
||||
}, [appendLog, wallet])
|
||||
|
||||
const logger = useMemo(() => ({
|
||||
ok: (message, context) => log('ok')(message, context),
|
||||
info: (message, context) => log('info')(message, context),
|
||||
error: (message, context) => log('error')(message, context)
|
||||
}), [log])
|
||||
|
||||
return { logger, deleteLogs }
|
||||
}
|
||||
|
||||
function tag (walletDef) {
|
||||
return walletDef.shortName || walletDef.name
|
||||
return { appendLog, deleteLogs }
|
||||
}
|
||||
|
||||
export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
|
||||
|
@ -227,7 +189,7 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
|
|||
console.log('cannot get client wallet logs: indexeddb not supported')
|
||||
} else {
|
||||
const indexName = walletDef ? 'wallet_ts' : 'ts'
|
||||
const query = walletDef ? window.IDBKeyRange.bound([tag(walletDef), -Infinity], [tag(walletDef), Infinity]) : null
|
||||
const query = walletDef ? window.IDBKeyRange.bound([walletTag(walletDef), -Infinity], [walletTag(walletDef), Infinity]) : null
|
||||
|
||||
result = await getPage(page, pageSize, indexName, query, 'prev')
|
||||
// if given wallet has no walletType it means logs are only stored in local IDB
|
||||
|
@ -272,7 +234,7 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
|
|||
|
||||
const newLogs = data.walletLogs.entries.map(({ createdAt, wallet: walletType, ...log }) => ({
|
||||
ts: +new Date(createdAt),
|
||||
wallet: tag(getWalletByType(walletType)),
|
||||
wallet: walletTag(getWalletByType(walletType)),
|
||||
...log
|
||||
}))
|
||||
const combinedLogs = uniqueSort([...result.data, ...newLogs])
|
||||
|
|
|
@ -221,3 +221,12 @@ export const SET_WALLET_PRIORITY = gql`
|
|||
setWalletPriority(id: $id, priority: $priority)
|
||||
}
|
||||
`
|
||||
|
||||
export const CANCEL_INVOICE = gql`
|
||||
${INVOICE_FIELDS}
|
||||
mutation cancelInvoice($hash: String!, $hmac: String!) {
|
||||
cancelInvoice(hash: $hash, hmac: $hmac) {
|
||||
...InvoiceFields
|
||||
}
|
||||
}
|
||||
`
|
||||
|
|
|
@ -27,6 +27,10 @@ export function getStorageKey (name, userId) {
|
|||
return storageKey
|
||||
}
|
||||
|
||||
export function walletTag (walletDef) {
|
||||
return walletDef.shortName || walletDef.name
|
||||
}
|
||||
|
||||
export function walletPrioritySort (w1, w2) {
|
||||
// enabled/configured wallets always come before disabled/unconfigured wallets
|
||||
if ((w1.config?.enabled && !w2.config?.enabled) || (isConfigured(w1) && !isConfigured(w2))) {
|
||||
|
|
|
@ -5,7 +5,7 @@ import { canReceive, canSend, getStorageKey, saveWalletLocally, siftConfig, upse
|
|||
import { useMutation } from '@apollo/client'
|
||||
import { generateMutation } from './graphql'
|
||||
import { REMOVE_WALLET } from '@/fragments/wallet'
|
||||
import { useWalletLogger } from '@/components/wallet-logger'
|
||||
import { useWalletLogger } from '@/wallets/logger'
|
||||
import { useWallets } from '.'
|
||||
import validateWallet from './validate'
|
||||
|
||||
|
@ -13,7 +13,7 @@ export function useWalletConfigurator (wallet) {
|
|||
const { me } = useMe()
|
||||
const { reloadLocalWallets } = useWallets()
|
||||
const { encrypt, isActive } = useVault()
|
||||
const { logger } = useWalletLogger(wallet?.def)
|
||||
const logger = useWalletLogger(wallet)
|
||||
const [upsertWallet] = useMutation(generateMutation(wallet?.def))
|
||||
const [removeWallet] = useMutation(REMOVE_WALLET)
|
||||
|
||||
|
@ -59,7 +59,7 @@ export function useWalletConfigurator (wallet) {
|
|||
}
|
||||
|
||||
return { clientConfig, serverConfig }
|
||||
}, [wallet])
|
||||
}, [wallet, logger])
|
||||
|
||||
const _detachFromServer = useCallback(async () => {
|
||||
await removeWallet({ variables: { id: wallet.config.id } })
|
||||
|
|
|
@ -1,22 +1,87 @@
|
|||
export class InvoiceCanceledError extends Error {
|
||||
constructor (hash, actionError) {
|
||||
super(actionError ?? `invoice canceled: ${hash}`)
|
||||
constructor (invoice, actionError) {
|
||||
super(actionError ?? `invoice canceled: ${invoice.hash}`)
|
||||
this.name = 'InvoiceCanceledError'
|
||||
this.hash = hash
|
||||
this.invoice = invoice
|
||||
this.actionError = actionError
|
||||
}
|
||||
}
|
||||
|
||||
export class NoAttachedWalletError extends Error {
|
||||
constructor () {
|
||||
super('no attached wallet found')
|
||||
this.name = 'NoAttachedWalletError'
|
||||
export class InvoiceExpiredError extends Error {
|
||||
constructor (invoice) {
|
||||
super(`invoice expired: ${invoice.hash}`)
|
||||
this.name = 'InvoiceExpiredError'
|
||||
this.invoice = invoice
|
||||
}
|
||||
}
|
||||
|
||||
export class InvoiceExpiredError extends Error {
|
||||
constructor (hash) {
|
||||
super(`invoice expired: ${hash}`)
|
||||
this.name = 'InvoiceExpiredError'
|
||||
export class WalletError extends Error {}
|
||||
export class WalletPaymentError extends WalletError {}
|
||||
export class WalletConfigurationError extends WalletError {}
|
||||
|
||||
export class WalletNotEnabledError extends WalletConfigurationError {
|
||||
constructor (name) {
|
||||
super(`wallet is not enabled: ${name}`)
|
||||
this.name = 'WalletNotEnabledError'
|
||||
this.wallet = name
|
||||
this.reason = 'wallet is not enabled'
|
||||
}
|
||||
}
|
||||
|
||||
export class WalletSendNotConfiguredError extends WalletConfigurationError {
|
||||
constructor (name) {
|
||||
super(`wallet send is not configured: ${name}`)
|
||||
this.name = 'WalletSendNotConfiguredError'
|
||||
this.wallet = name
|
||||
this.reason = 'wallet send is not configured'
|
||||
}
|
||||
}
|
||||
|
||||
export class WalletSenderError extends WalletPaymentError {
|
||||
constructor (name, invoice, message) {
|
||||
super(`${name} failed to pay invoice ${invoice.hash}: ${message}`)
|
||||
this.name = 'WalletSenderError'
|
||||
this.wallet = name
|
||||
this.invoice = invoice
|
||||
this.reason = message
|
||||
}
|
||||
}
|
||||
|
||||
export class WalletsNotAvailableError extends WalletConfigurationError {
|
||||
constructor () {
|
||||
super('no wallet available')
|
||||
this.name = 'WalletsNotAvailableError'
|
||||
}
|
||||
}
|
||||
|
||||
export class WalletAggregateError extends WalletError {
|
||||
constructor (errors, invoice) {
|
||||
super('WalletAggregateError')
|
||||
this.name = 'WalletAggregateError'
|
||||
this.errors = errors.reduce((acc, e) => {
|
||||
if (Array.isArray(e?.errors)) {
|
||||
acc.push(...e.errors)
|
||||
} else {
|
||||
acc.push(e)
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
this.invoice = invoice
|
||||
}
|
||||
}
|
||||
|
||||
export class WalletPaymentAggregateError extends WalletPaymentError {
|
||||
constructor (errors, invoice) {
|
||||
super('WalletPaymentAggregateError')
|
||||
this.name = 'WalletPaymentAggregateError'
|
||||
this.errors = errors.reduce((acc, e) => {
|
||||
if (Array.isArray(e?.errors)) {
|
||||
acc.push(...e.errors)
|
||||
} else {
|
||||
acc.push(e)
|
||||
}
|
||||
return acc
|
||||
}, []).filter(e => e instanceof WalletPaymentError)
|
||||
this.invoice = invoice
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,11 +5,8 @@ import { useApolloClient, useMutation, useQuery } from '@apollo/client'
|
|||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { getStorageKey, getWalletByType, walletPrioritySort, canSend, isConfigured, upsertWalletVariables, siftConfig, saveWalletLocally } from './common'
|
||||
import useVault from '@/components/vault/use-vault'
|
||||
import { useWalletLogger } from '@/components/wallet-logger'
|
||||
import { decode as bolt11Decode } from 'bolt11'
|
||||
import walletDefs from '@/wallets/client'
|
||||
import { generateMutation } from './graphql'
|
||||
import { formatSats } from '@/lib/format'
|
||||
|
||||
const WalletsContext = createContext({
|
||||
wallets: []
|
||||
|
@ -128,7 +125,7 @@ export function WalletsProvider ({ children }) {
|
|||
}
|
||||
}
|
||||
|
||||
// sort by priority, then add status field
|
||||
// sort by priority
|
||||
return Object.values(merged).sort(walletPrioritySort)
|
||||
}, [serverWallets, localWallets])
|
||||
|
||||
|
@ -218,34 +215,13 @@ export function useWallets () {
|
|||
|
||||
export function useWallet (name) {
|
||||
const { wallets } = useWallets()
|
||||
|
||||
const wallet = useMemo(() => {
|
||||
if (name) {
|
||||
return wallets.find(w => w.def.name === name)
|
||||
}
|
||||
|
||||
// return the first enabled wallet that is available and can send
|
||||
return wallets
|
||||
.filter(w => !w.def.isAvailable || w.def.isAvailable())
|
||||
.filter(w => w.config?.enabled && canSend(w))[0]
|
||||
}, [wallets, name])
|
||||
|
||||
const { logger } = useWalletLogger(wallet?.def)
|
||||
|
||||
const sendPayment = useCallback(async (bolt11) => {
|
||||
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 err
|
||||
}
|
||||
}, [wallet, logger])
|
||||
|
||||
if (!wallet) return null
|
||||
|
||||
return { ...wallet, sendPayment }
|
||||
return wallets.find(w => w.def.name === name)
|
||||
}
|
||||
|
||||
export function useSendWallets () {
|
||||
const { wallets } = useWallets()
|
||||
// return the first enabled wallet that is available and can send
|
||||
return wallets
|
||||
.filter(w => !w.def.isAvailable || w.def.isAvailable())
|
||||
.filter(w => w.config?.enabled && canSend(w))
|
||||
}
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors'
|
||||
import { bolt11Tags } from '@/lib/bolt11'
|
||||
import { Mutex } from 'async-mutex'
|
||||
export * from '@/wallets/lnc'
|
||||
|
||||
|
@ -15,20 +13,12 @@ export async function testSendPayment (credentials, { logger }) {
|
|||
}
|
||||
|
||||
export async function sendPayment (bolt11, credentials, { logger }) {
|
||||
const hash = bolt11Tags(bolt11).payment_hash
|
||||
return await mutex.runExclusive(async () => {
|
||||
try {
|
||||
const lnc = await getLNC(credentials, { logger })
|
||||
const { paymentError, paymentPreimage: preimage } = await lnc.lnd.lightning.sendPaymentSync({ payment_request: bolt11 })
|
||||
if (paymentError) throw new Error(paymentError)
|
||||
if (!preimage) throw new Error('No preimage in response')
|
||||
return preimage
|
||||
} catch (err) {
|
||||
const msg = err.message || err.toString?.()
|
||||
if (msg.includes('invoice expired')) throw new InvoiceExpiredError(hash)
|
||||
if (msg.includes('canceled')) throw new InvoiceCanceledError(hash)
|
||||
throw err
|
||||
}
|
||||
const lnc = await getLNC(credentials, { logger })
|
||||
const { paymentError, paymentPreimage: preimage } = await lnc.lnd.lightning.sendPaymentSync({ payment_request: bolt11 })
|
||||
if (paymentError) throw new Error(paymentError)
|
||||
if (!preimage) throw new Error('No preimage in response')
|
||||
return preimage
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
import { useCallback } from 'react'
|
||||
import { decode as bolt11Decode } from 'bolt11'
|
||||
import { formatMsats } from '@/lib/format'
|
||||
import { walletTag } from '@/wallets/common'
|
||||
import { useWalletLogManager } from '@/components/wallet-logger'
|
||||
|
||||
export function useWalletLoggerFactory () {
|
||||
const { appendLog } = useWalletLogManager()
|
||||
|
||||
const log = useCallback((wallet, level) => (message, context = {}) => {
|
||||
if (!wallet) {
|
||||
return
|
||||
}
|
||||
|
||||
if (context?.bolt11) {
|
||||
// automatically populate context from bolt11 to avoid duplicating this code
|
||||
const decoded = bolt11Decode(context.bolt11)
|
||||
context = {
|
||||
...context,
|
||||
amount: formatMsats(decoded.millisatoshis),
|
||||
payment_hash: decoded.tagsObject.payment_hash,
|
||||
description: decoded.tagsObject.description,
|
||||
created_at: new Date(decoded.timestamp * 1000).toISOString(),
|
||||
expires_at: new Date(decoded.timeExpireDate * 1000).toISOString(),
|
||||
// payments should affect wallet status
|
||||
status: true
|
||||
}
|
||||
}
|
||||
context.send = true
|
||||
|
||||
appendLog(wallet, level, message, context)
|
||||
console[level !== 'error' ? 'info' : 'error'](`[${walletTag(wallet.def)}]`, message)
|
||||
}, [appendLog])
|
||||
|
||||
return useCallback(wallet => ({
|
||||
ok: (message, context) => log(wallet, 'ok')(message, context),
|
||||
info: (message, context) => log(wallet, 'info')(message, context),
|
||||
error: (message, context) => log(wallet, 'error')(message, context)
|
||||
}), [log])
|
||||
}
|
||||
|
||||
export function useWalletLogger (wallet) {
|
||||
const factory = useWalletLoggerFactory()
|
||||
return factory(wallet)
|
||||
}
|
|
@ -0,0 +1,139 @@
|
|||
import { useCallback } from 'react'
|
||||
import { useSendWallets } from '@/wallets'
|
||||
import { formatSats } from '@/lib/format'
|
||||
import { useInvoice } from '@/components/payment'
|
||||
import { FAST_POLL_INTERVAL } from '@/lib/constants'
|
||||
import {
|
||||
WalletsNotAvailableError, WalletSenderError, WalletAggregateError, WalletPaymentAggregateError,
|
||||
WalletNotEnabledError, WalletSendNotConfiguredError, WalletPaymentError, WalletError
|
||||
} from '@/wallets/errors'
|
||||
import { canSend } from './common'
|
||||
import { useWalletLoggerFactory } from './logger'
|
||||
|
||||
export function useWalletPayment () {
|
||||
const wallets = useSendWallets()
|
||||
const sendPayment = useSendPayment()
|
||||
const invoiceHelper = useInvoice()
|
||||
|
||||
return useCallback(async (invoice, { waitFor, updateOnFallback }) => {
|
||||
let aggregateError = new WalletAggregateError([])
|
||||
let latestInvoice = invoice
|
||||
|
||||
// throw a special error that caller can handle separately if no payment was attempted
|
||||
if (wallets.length === 0) {
|
||||
throw new WalletsNotAvailableError()
|
||||
}
|
||||
|
||||
for (const [i, wallet] of wallets.entries()) {
|
||||
const controller = invoiceController(latestInvoice, 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.
|
||||
sendPayment(wallet, latestInvoice).catch(reject)
|
||||
controller.wait(waitFor)
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
})
|
||||
} catch (err) {
|
||||
// cancel invoice to make sure it cannot be paid later and create new invoice to retry.
|
||||
// we only need to do this if payment was attempted which is not the case if the wallet is not enabled.
|
||||
if (err instanceof WalletPaymentError) {
|
||||
await invoiceHelper.cancel(latestInvoice)
|
||||
|
||||
// is there another wallet to try?
|
||||
const lastAttempt = i === wallets.length - 1
|
||||
if (!lastAttempt) {
|
||||
latestInvoice = await invoiceHelper.retry(latestInvoice, { update: updateOnFallback })
|
||||
}
|
||||
}
|
||||
|
||||
// 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 WalletError) {
|
||||
aggregateError = new WalletAggregateError([aggregateError, err], latestInvoice)
|
||||
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
|
||||
throw new WalletPaymentAggregateError([aggregateError], latestInvoice)
|
||||
}, [wallets, invoiceHelper, sendPayment])
|
||||
}
|
||||
|
||||
function invoiceController (inv, isInvoice) {
|
||||
const controller = new AbortController()
|
||||
const signal = controller.signal
|
||||
controller.wait = async (waitFor = inv => inv?.actionState === 'PAID') => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
let updatedInvoice, paid
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
({ invoice: updatedInvoice, check: paid } = await isInvoice(inv, waitFor))
|
||||
if (paid) {
|
||||
resolve(updatedInvoice)
|
||||
clearInterval(interval)
|
||||
signal.removeEventListener('abort', abort)
|
||||
} else {
|
||||
console.info(`invoice #${inv.id}: waiting for payment ...`)
|
||||
}
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
clearInterval(interval)
|
||||
signal.removeEventListener('abort', abort)
|
||||
}
|
||||
}, FAST_POLL_INTERVAL)
|
||||
|
||||
const abort = () => {
|
||||
console.info(`invoice #${inv.id}: stopped waiting`)
|
||||
resolve(updatedInvoice)
|
||||
clearInterval(interval)
|
||||
signal.removeEventListener('abort', abort)
|
||||
}
|
||||
signal.addEventListener('abort', abort)
|
||||
})
|
||||
}
|
||||
|
||||
controller.stop = () => controller.abort()
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
function useSendPayment () {
|
||||
const factory = useWalletLoggerFactory()
|
||||
|
||||
return useCallback(async (wallet, invoice) => {
|
||||
const logger = factory(wallet)
|
||||
|
||||
if (!wallet.config.enabled) {
|
||||
throw new WalletNotEnabledError(wallet.def.name)
|
||||
}
|
||||
|
||||
if (!canSend(wallet)) {
|
||||
throw new WalletSendNotConfiguredError(wallet.def.name)
|
||||
}
|
||||
|
||||
const { bolt11, satsRequested } = invoice
|
||||
|
||||
logger.info(`↗ sending payment: ${formatSats(satsRequested)}`, { bolt11 })
|
||||
try {
|
||||
const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger })
|
||||
logger.ok(`↗ payment sent: ${formatSats(satsRequested)}`, { bolt11, preimage })
|
||||
} catch (err) {
|
||||
const message = err.message || err.toString?.()
|
||||
logger.error(`payment failed: ${message}`, { bolt11 })
|
||||
throw new WalletSenderError(wallet.def.name, invoice, message)
|
||||
}
|
||||
}, [factory])
|
||||
}
|
Loading…
Reference in New Issue