diff --git a/api/paidAction/index.js b/api/paidAction/index.js index ad067a3ca..473d4bc13 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -328,7 +328,9 @@ export async function retryPaidAction (actionType, args, incomingContext) { me: await models.user.findUnique({ where: { id: parseInt(me.id) } }), cost: BigInt(msatsRequested), actionId, - predecessorId: failedInvoice.id + predecessorId: failedInvoice.id, + // a locked invoice means we're retrying a payment from the beginning with all sender and receiver wallets + retry: failedInvoice.lockedAt ? failedInvoice.retry + 1 : failedInvoice.retry } let invoiceArgs @@ -419,7 +421,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, retry, predecessorId } = context const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs const db = tx ?? models @@ -445,6 +447,7 @@ async function createDbInvoice (actionType, args, context) { actionArgs: args, expiresAt, actionId, + retry, predecessorId } diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 26e8c4872..7ed345da4 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -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: { @@ -350,6 +351,7 @@ export default { WHERE "Invoice"."userId" = $1 AND "Invoice"."updated_at" < $2 AND "Invoice"."actionState" = 'FAILED' + AND "Invoice"."retry" >= ${WALLET_MAX_RETRIES} AND ( "Invoice"."actionType" = 'ITEM_CREATE' OR "Invoice"."actionType" = 'ZAP' OR diff --git a/api/resolvers/paidAction.js b/api/resolvers/paidAction.js index 2b993c1f0..bc2f5f50c 100644 --- a/api/resolvers/paidAction.js +++ b/api/resolvers/paidAction.js @@ -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) { @@ -67,6 +67,10 @@ export default { throw new Error(`Invoice is not in failed state: ${invoice.actionState}`) } + if (invoice.retry >= WALLET_MAX_RETRIES) { + throw new Error('Payment has been retried too many times') + } + const result = await retryPaidAction(invoice.actionType, { invoice }, { models, me, lnd }) return { diff --git a/api/resolvers/wallet.js b/api/resolvers/wallet.js index 8bcae59b8..daf8441d2 100644 --- a/api/resolvers/wallet.js +++ b/api/resolvers/wallet.js @@ -11,7 +11,8 @@ import { PAID_ACTION_PAYMENT_METHODS, WALLET_CREATE_INVOICE_TIMEOUT_MS, WALLET_RETRY_AFTER_MS, - WALLET_RETRY_BEFORE_MS + WALLET_RETRY_BEFORE_MS, + WALLET_MAX_RETRIES } from '@/lib/constants' import { amountSchema, validateSchema, withdrawlSchema, lnAddrSchema } from '@/lib/validate' import assertGofacYourself from './ofac' @@ -480,6 +481,7 @@ const resolvers = { "cancelledAt" + $3::interval ) AND "lockedAt" IS NULL + AND "retry" < $4 ORDER BY id DESC FOR UPDATE SKIP LOCKED ) @@ -497,7 +499,8 @@ const resolvers = { SELECT * FROM failed`, me.id, `${WALLET_RETRY_AFTER_MS} milliseconds`, - `${WALLET_RETRY_BEFORE_MS} milliseconds`) + `${WALLET_RETRY_BEFORE_MS} milliseconds`, + WALLET_MAX_RETRIES) } }, Wallet: { diff --git a/lib/constants.js b/lib/constants.js index dc78107ce..43d892acd 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -199,3 +199,5 @@ export const WALLET_CREATE_INVOICE_TIMEOUT_MS = 45_000 // 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 diff --git a/prisma/migrations/20250107084543_invoice_retry_count/migration.sql b/prisma/migrations/20250107084543_invoice_retry_count/migration.sql new file mode 100644 index 000000000..3a9765862 --- /dev/null +++ b/prisma/migrations/20250107084543_invoice_retry_count/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Invoice" ADD COLUMN "retry" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e74e94c80..31058eef4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -925,6 +925,7 @@ model Invoice { cancelledAt DateTime? userCancel Boolean? lockedAt DateTime? + retry Int @default(0) msatsRequested BigInt msatsReceived BigInt? desc String? diff --git a/wallets/server.js b/wallets/server.js index f14e9fb36..7aeb03475 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -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 }, { retry, predecessorId, models }) { // get the wallets in order of priority - const wallets = await getInvoiceableWallets(userId, { predecessorId, models }) + const wallets = await getInvoiceableWallets(userId, { + retry, + predecessorId, + models + }) msats = toPositiveNumber(msats) @@ -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 }) { + { retry, predecessorId, models, me, lnd }) { let logger, bolt11 try { const { invoice, wallet } = await createInvoice(userId, { @@ -90,7 +94,7 @@ export async function createWrappedInvoice (userId, description, descriptionHash, expiry - }, { predecessorId, models }) + }, { retry, predecessorId, models }) logger = walletLogger({ wallet, models }) bolt11 = invoice @@ -110,7 +114,7 @@ export async function createWrappedInvoice (userId, } } -export async function getInvoiceableWallets (userId, { predecessorId, models }) { +export async function getInvoiceableWallets (userId, { retry, 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. @@ -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"."retry" = ${retry} ) SELECT "InvoiceForward"."walletId"