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

Automated retries #1776

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
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
3 changes: 2 additions & 1 deletion api/paidAction/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ async function createSNInvoice (actionType, args, context) {
}

async function createDbInvoice (actionType, args, context) {
const { me, models, tx, cost, optimistic, actionId, invoiceArgs, predecessorId } = context
const { me, models, tx, cost, optimistic, actionId, invoiceArgs, paymentAttempt, predecessorId } = context
const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs

const db = tx ?? models
Expand All @@ -445,6 +445,7 @@ async function createDbInvoice (actionType, args, context) {
actionArgs: args,
expiresAt,
actionId,
paymentAttempt,
predecessorId
}

Expand Down
2 changes: 2 additions & 0 deletions api/resolvers/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { pushSubscriptionSchema, validateSchema } from '@/lib/validate'
import { replyToSubscription } from '@/lib/webPush'
import { getSub } from './sub'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { WALLET_MAX_RETRIES } from '@/lib/constants'

export default {
Query: {
Expand Down Expand Up @@ -350,6 +351,7 @@ export default {
WHERE "Invoice"."userId" = $1
AND "Invoice"."updated_at" < $2
AND "Invoice"."actionState" = 'FAILED'
AND "Invoice"."paymentAttempt" >= ${WALLET_MAX_RETRIES}
AND (
"Invoice"."actionType" = 'ITEM_CREATE' OR
"Invoice"."actionType" = 'ZAP' OR
Expand Down
11 changes: 9 additions & 2 deletions api/resolvers/paidAction.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { retryPaidAction } from '../paidAction'
import { USER_ID } from '@/lib/constants'
import { USER_ID, WALLET_MAX_RETRIES } from '@/lib/constants'

function paidActionType (actionType) {
switch (actionType) {
Expand Down Expand Up @@ -67,7 +67,14 @@ export default {
throw new Error(`Invoice is not in failed state: ${invoice.actionState}`)
}

const result = await retryPaidAction(invoice.actionType, { invoice }, { models, me, lnd })
// a locked invoice means we want to retry a payment from the beginning
// with all sender and receiver wallets so we need to increment the retry count
const paymentAttempt = invoice.lockedAt ? invoice.paymentAttempt + 1 : invoice.paymentAttempt
if (paymentAttempt > WALLET_MAX_RETRIES) {
throw new Error('Payment has been retried too many times')
}

const result = await retryPaidAction(invoice.actionType, { invoice }, { paymentAttempt, models, me, lnd })

return {
...result,
Expand Down
28 changes: 27 additions & 1 deletion api/resolvers/wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { formatMsats, msatsToSats, msatsToSatsDecimal, satsToMsats } from '@/lib
import {
USER_ID, INVOICE_RETENTION_DAYS,
PAID_ACTION_PAYMENT_METHODS,
WALLET_CREATE_INVOICE_TIMEOUT_MS
WALLET_CREATE_INVOICE_TIMEOUT_MS,
WALLET_RETRY_AFTER_MS,
WALLET_RETRY_BEFORE_MS,
WALLET_MAX_RETRIES
} from '@/lib/constants'
import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate'
import assertGofacYourself from './ofac'
Expand Down Expand Up @@ -456,6 +459,29 @@ const resolvers = {
cursor: nextCursor,
entries: logs
}
},
failedInvoices: async (parent, args, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
// make sure each invoice is only returned once via visibility timeouts and SKIP LOCKED
// we use queryRawUnsafe because Prisma does not support tsrange
// see https://www.prisma.io/docs/orm/overview/databases/postgresql
return await models.$queryRaw`
UPDATE "Invoice"
SET "lockedAt" = now()
WHERE id IN (
SELECT id FROM "Invoice"
WHERE "userId" = ${me.id}
AND "actionState" = 'FAILED'
AND "cancelledAt" < now() - ${`${WALLET_RETRY_AFTER_MS} milliseconds`}::interval
AND "cancelledAt" > now() - ${`${WALLET_RETRY_BEFORE_MS} milliseconds`}::interval
AND ("lockedAt" IS NULL OR "lockedAt" < now() - interval '1 minute')
AND "paymentAttempt" < ${WALLET_MAX_RETRIES}
ORDER BY id DESC
FOR UPDATE SKIP LOCKED
)
RETURNING *`
}
},
Wallet: {
Expand Down
1 change: 1 addition & 0 deletions api/typeDefs/wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ const typeDefs = `
wallet(id: ID!): Wallet
walletByType(type: String!): Wallet
walletLogs(type: String, from: String, to: String, cursor: String): WalletLog!
failedInvoices: [Invoice!]!
}

extend type Mutation {
Expand Down
6 changes: 3 additions & 3 deletions components/use-invoice.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useApolloClient, useMutation } from '@apollo/client'
import { useCallback } from 'react'
import { useCallback, useMemo } from 'react'
import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/errors'
import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
import { INVOICE, CANCEL_INVOICE } from '@/fragments/wallet'
Expand Down Expand Up @@ -42,7 +42,7 @@ export default function useInvoice () {
return data.cancelInvoice
}, [cancelInvoice])

const retry = useCallback(async ({ id, hash, hmac }, { update }) => {
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
Expand All @@ -53,5 +53,5 @@ export default function useInvoice () {
return newInvoice
}, [retryPaidAction])

return { cancel, retry, isInvoice }
return useMemo(() => ({ cancel, retry, isInvoice }), [cancel, retry, isInvoice])
Copy link
Member Author

@ekzyis ekzyis Jan 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This useMemo and the one in useSendWallets is pretty important since it avoids that waitForWalletPayment changes between renders which would mean that we run the useEffect for retries with the same failed invoices multiple times.

In general, this means it is very important that waitForWalletPayment is stable across renders.

}
9 changes: 9 additions & 0 deletions fragments/wallet.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,12 @@ export const CANCEL_INVOICE = gql`
}
}
`

export const FAILED_INVOICES = gql`
${INVOICE_FIELDS}
query FailedInvoices {
failedInvoices {
...InvoiceFields
}
}
`
6 changes: 6 additions & 0 deletions lib/apollo.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,12 @@ function getClient (uri) {
facts: [...(existing?.facts || []), ...incoming.facts]
}
}
},
failedInvoices: {
keyArgs: [],
merge (existing, incoming) {
return incoming
}
}
}
},
Expand Down
7 changes: 7 additions & 0 deletions lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,10 @@ export const ZAP_UNDO_DELAY_MS = 5_000

export const WALLET_SEND_PAYMENT_TIMEOUT_MS = 150_000
export const WALLET_CREATE_INVOICE_TIMEOUT_MS = 45_000
// When should failed invoices be returned to a client to retry?
// This must be high enough such that intermediate failed invoices that will be retried
// by the client due to sender or receiver fallbacks are not returned to the client.
export const WALLET_RETRY_AFTER_MS = 60_000
export const WALLET_RETRY_BEFORE_MS = 86_400_000 // 24 hours
// we want to attempt a payment three times so we retry two times
export const WALLET_MAX_RETRIES = 2
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "Invoice" ADD COLUMN "paymentAttempt" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "Invoice" ADD COLUMN "lockedAt" TIMESTAMP(3);
CREATE INDEX "Invoice_cancelledAt_idx" ON "Invoice"("cancelledAt");
3 changes: 3 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -924,6 +924,8 @@ model Invoice {
cancelled Boolean @default(false)
cancelledAt DateTime?
userCancel Boolean?
lockedAt DateTime?
paymentAttempt Int @default(0)
msatsRequested BigInt
msatsReceived BigInt?
desc String?
Expand Down Expand Up @@ -952,6 +954,7 @@ model Invoice {
@@index([confirmedIndex], map: "Invoice.confirmedIndex_index")
@@index([isHeld])
@@index([confirmedAt])
@@index([cancelledAt])
@@index([actionType])
@@index([actionState])
}
Expand Down
43 changes: 38 additions & 5 deletions wallets/index.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { useMe } from '@/components/me'
import { SET_WALLET_PRIORITY, WALLETS } from '@/fragments/wallet'
import { SSR } from '@/lib/constants'
import { FAILED_INVOICES, SET_WALLET_PRIORITY, WALLETS } from '@/fragments/wallet'
import { NORMAL_POLL_INTERVAL, SSR } from '@/lib/constants'
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 walletDefs from '@/wallets/client'
import { generateMutation } from './graphql'
import { useWalletPayment } from './payment'
import useInvoice from '@/components/use-invoice'

const WalletsContext = createContext({
wallets: []
Expand Down Expand Up @@ -204,7 +206,9 @@ export function WalletsProvider ({ children }) {
removeLocalWallets
}}
>
{children}
<RetryHandler>
{children}
</RetryHandler>
Comment on lines +209 to +211
Copy link
Member Author

@ekzyis ekzyis Jan 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RetryHandler isn't a context provider, but I think we should use more providers inside context providers nonetheless. There are too many in pages/_app.js.

For example, we could put all providers for the carousel (price, block height, chain fee) into the same component.

</WalletsContext.Provider>
)
}
Expand All @@ -221,7 +225,36 @@ export function useWallet (name) {
export function useSendWallets () {
const { wallets } = useWallets()
// return all enabled wallets that are available and can send
return wallets
return useMemo(() => wallets
.filter(w => !w.def.isAvailable || w.def.isAvailable())
.filter(w => w.config?.enabled && canSend(w))
.filter(w => w.config?.enabled && canSend(w)), [wallets])
}

function RetryHandler ({ children }) {
const failedInvoices = useFailedInvoices()
const waitForWalletPayment = useWalletPayment()
const invoiceHelper = useInvoice()

useEffect(() => {
(async () => {
for (const invoice of failedInvoices) {
const newInvoice = await invoiceHelper.retry(invoice)
waitForWalletPayment(newInvoice).catch(console.error)
}
})()
}, [failedInvoices, invoiceHelper, waitForWalletPayment])

return children
}

function useFailedInvoices () {
const wallets = useSendWallets()

const { data } = useQuery(FAILED_INVOICES, {
pollInterval: NORMAL_POLL_INTERVAL,
skip: wallets.length === 0,
notifyOnNetworkStatusChange: true
})

return data?.failedInvoices ?? []
}
2 changes: 1 addition & 1 deletion wallets/payment.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function useWalletPayment () {
const loggerFactory = useWalletLoggerFactory()
const invoiceHelper = useInvoice()

return useCallback(async (invoice, { waitFor, updateOnFallback }) => {
return useCallback(async (invoice, { waitFor, updateOnFallback } = {}) => {
let aggregateError = new WalletAggregateError([])
let latestInvoice = invoice

Expand Down
15 changes: 10 additions & 5 deletions wallets/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,13 @@ export default [lnd, cln, lnAddr, lnbits, nwc, phoenixd, blink, lnc, webln]

const MAX_PENDING_INVOICES_PER_WALLET = 25

export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { predecessorId, models }) {
export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { paymentAttempt, predecessorId, models }) {
// get the wallets in order of priority
const wallets = await getInvoiceableWallets(userId, { predecessorId, models })
const wallets = await getInvoiceableWallets(userId, {
paymentAttempt,
predecessorId,
models
})

msats = toPositiveNumber(msats)

Expand Down Expand Up @@ -81,7 +85,7 @@ export async function createInvoice (userId, { msats, description, descriptionHa

export async function createWrappedInvoice (userId,
{ msats, feePercent, description, descriptionHash, expiry = 360 },
{ predecessorId, models, me, lnd }) {
{ paymentAttempt, predecessorId, models, me, lnd }) {
let logger, bolt11
try {
const { invoice, wallet } = await createInvoice(userId, {
Expand All @@ -90,7 +94,7 @@ export async function createWrappedInvoice (userId,
description,
descriptionHash,
expiry
}, { predecessorId, models })
}, { paymentAttempt, predecessorId, models })

logger = walletLogger({ wallet, models })
bolt11 = invoice
Expand All @@ -110,7 +114,7 @@ export async function createWrappedInvoice (userId,
}
}

export async function getInvoiceableWallets (userId, { predecessorId, models }) {
export async function getInvoiceableWallets (userId, { paymentAttempt, predecessorId, models }) {
// filter out all wallets that have already been tried by recursively following the retry chain of predecessor invoices.
// the current predecessor invoice is in state 'FAILED' and not in state 'RETRYING' because we are currently retrying it
// so it has not been updated yet.
Expand Down Expand Up @@ -143,6 +147,7 @@ export async function getInvoiceableWallets (userId, { predecessorId, models })
FROM "Invoice"
JOIN "Retries" ON "Invoice"."id" = "Retries"."predecessorId"
WHERE "Invoice"."actionState" = 'RETRYING'
AND "Invoice"."paymentAttempt" = ${paymentAttempt}
)
SELECT
"InvoiceForward"."walletId"
Expand Down
Loading