From a34aada2c65d8f9d6171d0bb573e09467c12b1ac Mon Sep 17 00:00:00 2001 From: ekzyis Date: Thu, 5 Dec 2024 18:17:19 +0100 Subject: [PATCH] Receiver fallbacks --- api/paidAction/index.js | 48 +++++++------ components/use-invoice.js | 14 +++- .../migration.sql | 14 ++++ prisma/schema.prisma | 44 ++++++------ wallets/errors.js | 8 +++ wallets/payment.js | 68 ++++++++++++------- wallets/server.js | 62 ++++++++++++----- 7 files changed, 172 insertions(+), 86 deletions(-) create mode 100644 prisma/migrations/20241206112911_failed_invoice_id/migration.sql diff --git a/api/paidAction/index.js b/api/paidAction/index.js index cc9ced4ae..8dc789bba 100644 --- a/api/paidAction/index.js +++ b/api/paidAction/index.js @@ -317,34 +317,39 @@ export async function retryPaidAction (actionType, args, incomingContext) { optimistic: actionOptimistic, me: await models.user.findUnique({ where: { id: parseInt(me.id) } }), cost: BigInt(msatsRequested), - actionId + actionId, + failedInvoiceId: failedInvoice.id } let invoiceArgs const invoiceForward = await models.invoiceForward.findUnique({ - where: { invoiceId: failedInvoice.id }, + where: { + invoiceId: failedInvoice.id + }, include: { - wallet: true, - invoice: true, - withdrawl: true + wallet: 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) + + if (invoiceForward) { + // this is a wrapped invoice, we need to retry it with receiver fallbacks + try { + const { userId } = invoiceForward.wallet + // this will return an invoice from the first receiver wallet that didn't fail yet and throw if none is available + 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 } + } catch (err) { + console.log('failed to retry wrapped invoice, falling back to SN:', err) + } } + invoiceArgs ??= await createSNInvoice(actionType, actionArgs, retryContext) + return await models.$transaction(async tx => { const context = { ...retryContext, tx, invoiceArgs } @@ -404,7 +409,7 @@ async function createSNInvoice (actionType, args, context) { } async function createDbInvoice (actionType, args, context) { - const { me, models, tx, cost, optimistic, actionId, invoiceArgs } = context + const { me, models, tx, cost, optimistic, actionId, invoiceArgs, failedInvoiceId } = context const { bolt11, wrappedBolt11, preimage, wallet, maxFee } = invoiceArgs const db = tx ?? models @@ -429,7 +434,8 @@ async function createDbInvoice (actionType, args, context) { actionOptimistic: optimistic, actionArgs: args, expiresAt, - actionId + actionId, + failedInvoiceId } let invoice diff --git a/components/use-invoice.js b/components/use-invoice.js index 977aa2d77..526600631 100644 --- a/components/use-invoice.js +++ b/components/use-invoice.js @@ -1,8 +1,8 @@ import { useApolloClient, useMutation } from '@apollo/client' import { useCallback } from 'react' +import { InvoiceCanceledError, InvoiceExpiredError, WalletReceiverError } from '@/wallets/errors' import { RETRY_PAID_ACTION } from '@/fragments/paidAction' import { INVOICE, CANCEL_INVOICE } from '@/fragments/wallet' -import { InvoiceExpiredError, InvoiceCanceledError } from '@/wallets/errors' export default function useInvoice () { const client = useApolloClient() @@ -16,14 +16,22 @@ export default function useInvoice () { throw error } - const { cancelled, cancelledAt, actionError, actionState, expiresAt } = data.invoice + const { cancelled, cancelledAt, actionError, actionState, expiresAt, isHeld } = data.invoice const expired = cancelledAt && new Date(expiresAt) < new Date(cancelledAt) if (expired) { throw new InvoiceExpiredError(data.invoice) } - if (cancelled || actionError) { + const failed = cancelled || actionError + + // failed forwards might already have been finalized (actionState === 'FAILED') + // so we also check for held payments + if ((failed && isHeld) || actionState === 'FAILED_FORWARD') { + throw new WalletReceiverError(data.invoice) + } + + if (failed) { throw new InvoiceCanceledError(data.invoice, actionError) } diff --git a/prisma/migrations/20241206112911_failed_invoice_id/migration.sql b/prisma/migrations/20241206112911_failed_invoice_id/migration.sql new file mode 100644 index 000000000..d44dba7cd --- /dev/null +++ b/prisma/migrations/20241206112911_failed_invoice_id/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - A unique constraint covering the columns `[failedInvoiceId]` on the table `Invoice` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "Invoice" ADD COLUMN "failedInvoiceId" INTEGER; + +-- CreateIndex +CREATE UNIQUE INDEX "Invoice.failedInvoiceId_unique" ON "Invoice"("failedInvoiceId"); + +-- AddForeignKey +ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_failedInvoiceId_fkey" FOREIGN KEY ("failedInvoiceId") REFERENCES "Invoice"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 65b6b7a36..b9cd148b5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -904,27 +904,29 @@ model ItemMention { } model Invoice { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") - userId Int - hash String @unique(map: "Invoice.hash_unique") - preimage String? @unique(map: "Invoice.preimage_unique") - isHeld Boolean? - bolt11 String - expiresAt DateTime - confirmedAt DateTime? - confirmedIndex BigInt? - cancelled Boolean @default(false) - cancelledAt DateTime? - msatsRequested BigInt - msatsReceived BigInt? - desc String? - comment String? - lud18Data Json? - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - invoiceForward InvoiceForward? - + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + userId Int + hash String @unique(map: "Invoice.hash_unique") + preimage String? @unique(map: "Invoice.preimage_unique") + isHeld Boolean? + bolt11 String + expiresAt DateTime + confirmedAt DateTime? + confirmedIndex BigInt? + cancelled Boolean @default(false) + cancelledAt DateTime? + msatsRequested BigInt + msatsReceived BigInt? + desc String? + comment String? + lud18Data Json? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + invoiceForward InvoiceForward? + failedInvoiceId Int? @unique(map: "Invoice.failedInvoiceId_unique") + failedInvoice Invoice? @relation("InvoiceChain", fields: [failedInvoiceId], references: [id], onDelete: Cascade) + retryInvoice Invoice? @relation("InvoiceChain") actionState InvoiceActionState? actionType InvoiceActionType? actionOptimistic Boolean? diff --git a/wallets/errors.js b/wallets/errors.js index 510d8e78b..13c9ca18c 100644 --- a/wallets/errors.js +++ b/wallets/errors.js @@ -47,6 +47,14 @@ export class WalletSenderError extends WalletPaymentError { } } +export class WalletReceiverError extends WalletPaymentError { + constructor (invoice) { + super(`payment forwarding failed for invoice ${invoice.hash}`) + this.name = 'WalletReceiverError' + this.invoice = invoice + } +} + export class WalletsNotAvailableError extends WalletConfigurationError { constructor () { super('no wallet available') diff --git a/wallets/payment.js b/wallets/payment.js index 157e06ead..d04b3f1ca 100644 --- a/wallets/payment.js +++ b/wallets/payment.js @@ -5,7 +5,7 @@ import useInvoice from '@/components/use-invoice' import { FAST_POLL_INTERVAL } from '@/lib/constants' import { WalletsNotAvailableError, WalletSenderError, WalletAggregateError, WalletPaymentAggregateError, - WalletNotEnabledError, WalletSendNotConfiguredError, WalletPaymentError, WalletError + WalletNotEnabledError, WalletSendNotConfiguredError, WalletPaymentError, WalletError, WalletReceiverError } from '@/wallets/errors' import { canSend } from './common' import { useWalletLoggerFactory } from './logger' @@ -24,44 +24,61 @@ export function useWalletPayment () { throw new WalletsNotAvailableError() } - for (const [i, wallet] of wallets.entries()) { + for (let i = 0; i < wallets.length; i++) { + const wallet = wallets[i] const controller = invoiceController(latestInvoice, invoiceHelper.isInvoice) + + const walletPromise = sendPayment(wallet, latestInvoice) + const pollPromise = controller.wait(waitFor) + 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) + walletPromise.catch(reject) + pollPromise.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) + let paymentError = err - // is there another wallet to try? - const lastAttempt = i === wallets.length - 1 - if (!lastAttempt) { - latestInvoice = await invoiceHelper.retry(latestInvoice, { update: updateOnFallback }) + if (!(paymentError instanceof WalletError)) { + // payment failed for some reason unrelated to wallets (ie invoice expired or was canceled). + // bail out of attempting wallets. + throw paymentError + } + + // at this point, paymentError is always a wallet error, + // we just need to distinguish between receiver and sender errors + + try { + // we always await the poll promise here to check for failed forwards since sender wallet errors + // can be caused by them which we want to handle as receiver errors, not sender errors. + await pollPromise + } catch (err) { + if (err instanceof WalletError) { + paymentError = err } } - // TODO: receiver fallbacks - // - // if payment failed because of the receiver, we should use the same wallet again. - // if (err instanceof ReceiverError) { ... } + if (paymentError instanceof WalletReceiverError) { + // if payment failed because of the receiver, use the same wallet again. + i -= 1 + } + + if (paymentError instanceof WalletPaymentError) { + // if a payment was attempted, cancel invoice to make sure it cannot be paid later and create new invoice to retry. + await invoiceHelper.cancel(latestInvoice) + } - // 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 + // only create a new invoice if we will try to pay with a wallet again + const retry = paymentError instanceof WalletReceiverError || i < wallets.length - 1 + if (retry) { + latestInvoice = await invoiceHelper.retry(latestInvoice, { update: updateOnFallback }) } - // payment failed not because of the sender or receiver wallet. bail out of attemping wallets. - throw err + aggregateError = new WalletAggregateError([aggregateError, paymentError], latestInvoice) + + continue } finally { controller.stop() } @@ -131,6 +148,7 @@ function useSendPayment () { const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger }) logger.ok(`↗ payment sent: ${formatSats(satsRequested)}`, { bolt11, preimage }) } catch (err) { + // TODO: avoid logging confusing payment error if receiver failed and we canceled the invoice const message = err.message || err.toString?.() logger.error(`payment failed: ${message}`, { bolt11 }) throw new WalletSenderError(wallet.def.name, invoice, message) diff --git a/wallets/server.js b/wallets/server.js index c329d7670..e3cac197d 100644 --- a/wallets/server.js +++ b/wallets/server.js @@ -24,9 +24,9 @@ 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 }, { models }) { +export async function createInvoice (userId, { msats, description, descriptionHash, expiry = 360 }, { failedInvoiceId, models }) { // get the wallets in order of priority - const wallets = await getInvoiceableWallets(userId, { models }) + const wallets = await getInvoiceableWallets(userId, { failedInvoiceId, models }) msats = toPositiveNumber(msats) @@ -81,7 +81,7 @@ export async function createInvoice (userId, { msats, description, descriptionHa export async function createWrappedInvoice (userId, { msats, feePercent, description, descriptionHash, expiry = 360 }, - { models, me, lnd }) { + { failedInvoiceId, models, me, lnd }) { let logger, bolt11 try { const { invoice, wallet } = await createInvoice(userId, { @@ -90,7 +90,7 @@ export async function createWrappedInvoice (userId, description, descriptionHash, expiry - }, { models }) + }, { failedInvoiceId, models }) logger = walletLogger({ wallet, models }) bolt11 = invoice @@ -110,18 +110,48 @@ export async function createWrappedInvoice (userId, } } -export async function getInvoiceableWallets (userId, { models }) { - const wallets = await models.wallet.findMany({ - where: { userId, enabled: true }, - include: { - user: true - }, - orderBy: [ - { priority: 'asc' }, - // use id as tie breaker (older wallet first) - { id: 'asc' } - ] - }) +export async function getInvoiceableWallets (userId, { failedInvoiceId, models }) { + // filter out all wallets that have already been tried by recursively following the chain of retried invoices. + // the current failed invoice is in state 'FAILED' and not in state 'RETRYING' + // because we are currently retrying it so it has not been updated yet. + // if failedInvoiceId is not provided, the subquery will be empty and thus no wallets are filtered out. + const wallets = await models.$queryRaw` + SELECT + "Wallet".*, + jsonb_build_object( + 'id', "users"."id", + 'hideInvoiceDesc', "users"."hideInvoiceDesc" + ) AS "user" + FROM "Wallet" + JOIN "users" ON "users"."id" = "Wallet"."userId" + WHERE + "Wallet"."userId" = ${userId} + AND "Wallet"."enabled" = true + AND "Wallet"."id" NOT IN ( + WITH RECURSIVE "Retries" AS ( + -- select the current failed invoice that we are currently retrying + -- this failed invoice will be used to start the recursion + SELECT "Invoice"."id", "Invoice"."failedInvoiceId" + FROM "Invoice" + WHERE "Invoice"."id" = ${failedInvoiceId} AND "Invoice"."actionState" = 'FAILED' + + UNION ALL + + -- recursive part: use failedInvoiceId to select the previous invoice that failed in the chain + -- until there is no more previous invoice + SELECT "Invoice"."id", "Invoice"."failedInvoiceId" + FROM "Invoice" + JOIN "Retries" ON "Invoice"."id" = "Retries"."failedInvoiceId" + WHERE "Invoice"."actionState" = 'RETRYING' + ) + SELECT + "Wallet"."id" + FROM "Retries" + JOIN "InvoiceForward" ON "InvoiceForward"."invoiceId" = "Retries"."id" + JOIN "Wallet" ON "Wallet"."id" = "InvoiceForward"."walletId" + WHERE "InvoiceForward"."withdrawlId" IS NOT NULL + ) + ORDER BY "Wallet"."priority" ASC, "Wallet"."id" ASC` const walletsWithDefs = wallets.map(wallet => { const w = walletDefs.find(w => w.walletType === wallet.type)