Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve wallet fallbacks #1489

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
419 changes: 247 additions & 172 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
Loading