Skip to content

Commit

Permalink
fallbacks
Browse files Browse the repository at this point in the history
  • Loading branch information
riccardobl committed Oct 24, 2024
1 parent aba212f commit 5845b5f
Show file tree
Hide file tree
Showing 12 changed files with 560 additions and 309 deletions.
417 changes: 246 additions & 171 deletions api/paidAction/index.js

Large diffs are not rendered by default.

38 changes: 21 additions & 17 deletions api/resolvers/paidAction.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,28 +46,32 @@ export default {
}
},
Mutation: {
retryPaidAction: async (parent, { invoiceId }, { models, me, lnd }) => {
if (!me) {
throw new Error('You must be logged in')
}

const invoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me.id } })
if (!invoice) {
throw new Error('Invoice not found')
}
retryPaidAction: async (parent, { invoiceId, forceFeeCredits }, { models, me, lnd }) => {
try {
if (!me) {
throw new Error('You must be logged in')
}

if (invoice.actionState !== 'FAILED') {
if (invoice.actionState === 'PAID') {
throw new Error('Invoice is already paid')
const invoice = await models.invoice.findUnique({ where: { id: invoiceId, userId: me.id } })
if (!invoice) {
throw new Error('Invoice not found')
}
throw new Error(`Invoice is not in failed state: ${invoice.actionState}`)
}

const result = await retryPaidAction(invoice.actionType, { invoice }, { models, me, lnd })
if (invoice.actionState !== 'FAILED') {
if (invoice.actionState === 'PAID') {
throw new Error('Invoice is already paid')
}
throw new Error(`Invoice is not in failed state: ${invoice.actionState}`)
}

return {
...result,
type: paidActionType(invoice.actionType)
return {
type: paidActionType(invoice.actionType),
...await retryPaidAction(invoice.actionType, { invoice, forceFeeCredits }, { models, me, lnd })
}
} catch (error) {
console.log('Error in retryPaidAction: ', error)
throw error
}
}
},
Expand Down
20 changes: 13 additions & 7 deletions api/typeDefs/paidAction.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ extend type Query {
}
extend type Mutation {
retryPaidAction(invoiceId: Int!): PaidAction!
retryPaidAction(invoiceId: Int!,forceFeeCredits: Boolean): PaidAction
}
enum PaymentMethod {
Expand All @@ -18,37 +18,43 @@ enum PaymentMethod {
interface PaidAction {
invoice: Invoice
paymentMethod: PaymentMethod!
paymentMethod: PaymentMethod!,
canRetry: Boolean
}
type ItemPaidAction implements PaidAction {
result: Item
invoice: Invoice
paymentMethod: PaymentMethod!
paymentMethod: PaymentMethod!,
canRetry: Boolean
}
type ItemActPaidAction implements PaidAction {
result: ItemActResult
invoice: Invoice
paymentMethod: PaymentMethod!
paymentMethod: PaymentMethod!,
canRetry: Boolean
}
type PollVotePaidAction implements PaidAction {
result: PollVoteResult
invoice: Invoice
paymentMethod: PaymentMethod!
paymentMethod: PaymentMethod!,
canRetry: Boolean
}
type SubPaidAction implements PaidAction {
result: Sub
invoice: Invoice
paymentMethod: PaymentMethod!
paymentMethod: PaymentMethod!,
canRetry: Boolean
}
type DonatePaidAction implements PaidAction {
result: DonateResult
invoice: Invoice
paymentMethod: PaymentMethod!
paymentMethod: PaymentMethod!,
canRetry: Boolean
}
`
10 changes: 5 additions & 5 deletions components/payment.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useCallback, useMemo } from 'react'
import { useMe } from './me'
import { gql, useApolloClient, useMutation } from '@apollo/client'
import { useWallet } from 'wallets'
import { usePayer } from 'wallets'
import { FAST_POLL_INTERVAL, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
import { INVOICE } from '@/fragments/wallet'
import Invoice from '@/components/invoice'
Expand Down Expand Up @@ -134,17 +134,17 @@ export const useInvoice = () => {

export const useWalletPayment = () => {
const invoice = useInvoice()
const wallet = useWallet()
const payer = usePayer()

const waitForWalletPayment = useCallback(async ({ id, bolt11 }, waitFor) => {
if (!wallet) {
if (!payer) {
throw new NoAttachedWalletError()
}
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)
payer.pay(bolt11).catch(reject)
invoice.waitUntilPaid({ id }, waitFor)
.then(resolve)
.catch(reject)
Expand All @@ -155,7 +155,7 @@ export const useWalletPayment = () => {
} finally {
invoice.stopWaiting()
}
}, [wallet, invoice])
}, [payer, invoice])

return waitForWalletPayment
}
Expand Down
10 changes: 5 additions & 5 deletions components/qr.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,25 @@ import { QRCodeSVG } from 'qrcode.react'
import { CopyInput, InputSkeleton } from './form'
import InvoiceStatus from './invoice-status'
import { useEffect } from 'react'
import { useWallet } from 'wallets'
import { usePayer } from 'wallets'
import Bolt11Info from './bolt11-info'

export default function Qr ({ asIs, value, useWallet: automated, statusVariant, description, status }) {
const qrValue = asIs ? value : 'lightning:' + value.toUpperCase()
const wallet = useWallet()
const payer = usePayer()

useEffect(() => {
async function effect () {
if (automated && wallet) {
if (automated && payer) {
try {
await wallet.sendPayment(value)
await payer.pay(value)
} catch (e) {
console.log(e?.message)
}
}
}
effect()
}, [wallet])
}, [payer])

return (
<>
Expand Down
142 changes: 117 additions & 25 deletions components/use-paid-mutation.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
import { useCallback, useState } from 'react'
import { InvoiceCanceledError, InvoiceExpiredError, useInvoice, useQrPayment, useWalletPayment } from './payment'
import { GET_PAID_ACTION } from '@/fragments/paidAction'
import { GET_PAID_ACTION, RETRY_PAID_ACTION } from '@/fragments/paidAction'

/*
this is just like useMutation with a few changes:
Expand All @@ -22,32 +22,119 @@ export function usePaidMutation (mutation,
const [getPaidAction] = useLazyQuery(GET_PAID_ACTION, {
fetchPolicy: 'network-only'
})
const [retryPaidAction] = useMutation(RETRY_PAID_ACTION)

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
const waitForPayment = useCallback(async (invoice, { alwaysShowQROnFailure = false, persistOnNavigate = false, waitFor }, onMutationResponseUpdate) => {
const walletErrors = []
const paymentErrors = []

let canRetry = true
let updatedMutationResponse = null
let updatedMutationRest = null

/**
* Get a new invoice from the server
* @param {*} retryInvoice
* @param {boolean} withFeeCredit - try to pay with CC if possible
*/
const retry = async (retryInvoice, withFeeCredit) => {
try {
console.log('Retrying payment with fee credit:', withFeeCredit)
const { data: retryData, ...retryRest } = await retryPaidAction({ variables: { invoiceId: Number(retryInvoice.id), forceFeeCredits: withFeeCredit } })
const mutationResponse = retryData ? Object.values(retryData)[0] : null
const mutationRest = retryRest
const invoice = mutationResponse?.invoice
const canRetry = mutationResponse?.canRetry
return { invoice, canRetry, mutationResponse, mutationRest }
} catch (e) {
console.error(e)
throw new Error('Failed to retry payment', e)
}
}

let success = false

// Try p2p payment
while (!success) {
try {
if (!invoice) {
console.warn('usePaidMutation: invoice is undefined, this is unexpected')
break
}
await waitForWalletPayment(invoice, waitFor)
success = true
} catch (err) {
const isWalletError = !(err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError)

if (isWalletError) walletErrors.push(err)
else paymentErrors.push(err)

// cancel the invoice so it can be retried
await invoiceHelper.cancel(invoice)

if (canRetry) {
// not the last receiver, so we try the next one
const retryResult = await retry(invoice, false)
invoice = retryResult.invoice
canRetry = retryResult.canRetry
updatedMutationResponse = retryResult.mutationResponse
updatedMutationRest = retryResult.mutationRest
} else {
break
}
}
}

// Try CC payment
if (!success) {
// still no success after all receivers have been tried
// so we fallback to CC wallet
const retryResult = await retry(invoice, true)
invoice = retryResult.invoice
canRetry = retryResult.canRetry
updatedMutationResponse = retryResult.mutationResponse
updatedMutationRest = retryResult.mutationRest
// if an invoice is returned, it means the CC payment failed
success = !invoice
}

// Last resort: QR code payment if enabled
if (!success) {
if (!alwaysShowQROnFailure) {
await invoiceHelper.cancel(invoice)
} else {
// show the qr code payment modal with last wallet error
await waitForQrPayment(invoice, walletErrors[walletErrors.length - 1], { persistOnNavigate, waitFor })
success = true
}
walletError = err
}
return await waitForQrPayment(invoice, walletError, { persistOnNavigate, waitFor })

// Results
if (success) {
if (updatedMutationResponse && onMutationResponseUpdate) {
// sometimes we need to rerun the action to retry it on the server
// so we ensures that the response is passed back in case it changes
onMutationResponseUpdate(updatedMutationResponse, updatedMutationRest)
}
// just print the errors as warnings
if (walletErrors.length > 0) {
console.warn('usePaidMutation: successfully paid invoice but some wallet errors occurred', walletErrors)
}
if (paymentErrors.length > 0) {
console.warn('usePaidMutation: successfully paid invoice but some payment errors occurred', paymentErrors)
}
} else {
// everything failed, we throw an error
console.error('usePaidMutation: failed to pay invoice', walletErrors, paymentErrors)
throw new Error('Failed to pay invoice ', paymentErrors.map(e => e.message).join(', '), walletErrors.map(e => e.message).join(', '))
}
}, [waitForWalletPayment, waitForQrPayment, invoiceHelper])

const innerMutate = useCallback(async ({
Expand All @@ -67,11 +154,11 @@ export function usePaidMutation (mutation,
if (Object.values(data).length !== 1) {
throw new Error('usePaidMutation: exactly one mutation at a time is supported')
}
const response = Object.values(data)[0]
const invoice = response?.invoice

let response = Object.values(data)[0]

// if the mutation returns an invoice, pay it
if (invoice) {
if (response?.invoice) {
// adds payError, escalating to a normal error if the invoice is not canceled or
// has an actionError
const addPayError = (e, rest) => ({
Expand All @@ -85,7 +172,7 @@ export function usePaidMutation (mutation,
// 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(response.invoice, { persistOnNavigate, waitFor }).then(() => {
onPaid?.(client.cache, { data })
}).catch(e => {
console.error('usePaidMutation: failed to pay invoice', e)
Expand All @@ -98,16 +185,21 @@ export function usePaidMutation (mutation,
// the action is pessimistic
try {
// wait for the invoice to be paid
await waitForPayment(invoice, { alwaysShowQROnFailure: true, persistOnNavigate, waitFor })
await waitForPayment(response.invoice, { alwaysShowQROnFailure: true, persistOnNavigate, waitFor }, (newMutationResponse, newMutationRest) => {
const k = Object.keys(data)[0]
data[k] = newMutationResponse
rest = newMutationRest
response = data[k]
})
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) } })
const { data: { paidAction } } = await getPaidAction({ variables: { invoiceId: parseInt(response.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 }
invoice: { ...paidAction.invoice, hmac: response.invoice.hmac }
}
}
// we need to run update functions on mutations now that we have the data
Expand Down
Loading

0 comments on commit 5845b5f

Please sign in to comment.