diff --git a/core/api/src/app/wallets/decline-single-pending-invoice.ts b/core/api/src/app/wallets/decline-single-pending-invoice.ts index e6ec6d257f..96ae92996a 100644 --- a/core/api/src/app/wallets/decline-single-pending-invoice.ts +++ b/core/api/src/app/wallets/decline-single-pending-invoice.ts @@ -1,5 +1,6 @@ import { ProcessPendingInvoiceResult, + ProcessPendingInvoiceResultType, ProcessedReason, } from "./process-pending-invoice-result" @@ -13,6 +14,10 @@ import { LndService } from "@/services/lnd" import { elapsedSinceTimestamp } from "@/utils" +const assertUnreachable = (x: never): never => { + throw new Error(`This should never compile with ${x}`) +} + export const declineHeldInvoice = wrapAsyncToRunInSpan({ namespace: "app.invoices", fnName: "declineHeldInvoice", @@ -38,22 +43,28 @@ export const declineHeldInvoice = wrapAsyncToRunInSpan({ logger: pendingInvoiceLogger, }) - if (result.isProcessed) { - const processingCompletedInvoice = - await WalletInvoicesRepository().markAsProcessingCompleted(paymentHash) - if (processingCompletedInvoice instanceof Error) { - pendingInvoiceLogger.error("Unable to mark invoice as processingCompleted") - } - } + const walletInvoices = WalletInvoicesRepository() + let marked: WalletInvoiceWithOptionalLnInvoice | RepositoryError + switch (result.type) { + case ProcessPendingInvoiceResultType.MarkProcessedAsCanceledOrExpired: + marked = await walletInvoices.markAsProcessingCompleted(paymentHash) + if (marked instanceof Error) { + pendingInvoiceLogger.error("Unable to mark invoice as processingCompleted") + return marked + } + return true + + case ProcessPendingInvoiceResultType.Error: + return result.error - const error = "error" in result && result.error - return !(result.isPaid || result.isProcessed) - ? false - : result.isProcessed - ? false - : error - ? error - : result.isPaid + case ProcessPendingInvoiceResultType.MarkProcessedAsPaid: + case ProcessPendingInvoiceResultType.MarkProcessedAsPaidWithError: + case ProcessPendingInvoiceResultType.ReasonInvoiceNotPaidYet: + return true + + default: + return assertUnreachable(result) + } }, }) @@ -70,21 +81,23 @@ export const processPendingInvoiceForDecline = async ({ // Fetch invoice from lnd service const lndService = LndService() if (lndService instanceof Error) { - return ProcessPendingInvoiceResult.paidWithError(lndService) + return ProcessPendingInvoiceResult.err(lndService) } const lnInvoiceLookup = await lndService.lookupInvoice({ pubkey, paymentHash }) if (lnInvoiceLookup instanceof InvoiceNotFoundError) { - return ProcessPendingInvoiceResult.processedOnly(ProcessedReason.InvoiceNotFound) + return ProcessPendingInvoiceResult.processAsCanceledOrExpired( + ProcessedReason.InvoiceNotFound, + ) } if (lnInvoiceLookup instanceof Error) { - return ProcessPendingInvoiceResult.paidWithError(lnInvoiceLookup) + return ProcessPendingInvoiceResult.err(lnInvoiceLookup) } // Check status on invoice fetched from lnd const { isSettled, isHeld } = lnInvoiceLookup if (isSettled) { - return ProcessPendingInvoiceResult.paidWithError( + return ProcessPendingInvoiceResult.processAsPaidWithError( new InvalidNonHodlInvoiceError(JSON.stringify({ paymentHash })), ) } @@ -102,8 +115,8 @@ export const processPendingInvoiceForDecline = async ({ ) const invoiceSettled = await lndService.cancelInvoice({ pubkey, paymentHash }) if (invoiceSettled instanceof Error) { - return ProcessPendingInvoiceResult.paidWithError(invoiceSettled) + return ProcessPendingInvoiceResult.err(invoiceSettled) } - return ProcessPendingInvoiceResult.ok() + return ProcessPendingInvoiceResult.processAsPaid() } diff --git a/core/api/src/app/wallets/index.types.d.ts b/core/api/src/app/wallets/index.types.d.ts index 280d14186e..e9ef7a0534 100644 --- a/core/api/src/app/wallets/index.types.d.ts +++ b/core/api/src/app/wallets/index.types.d.ts @@ -135,23 +135,25 @@ type LnurlPaymentSendArgs = { type ProcessedReason = (typeof import("./process-pending-invoice-result").ProcessedReason)[keyof typeof import("./process-pending-invoice-result").ProcessedReason] +type ProcessPendingInvoiceResultType = + (typeof import("./process-pending-invoice-result").ProcessPendingInvoiceResultType)[keyof typeof import("./process-pending-invoice-result").ProcessPendingInvoiceResultType] + type ProcessPendingInvoiceResult = | { - isProcessed: true - isPaid: true + type: "markProcessedAsPaid" } | { - isProcessed: false - isPaid: true - error?: ApplicationError + type: "markProcessedAsPaidWithError" + error: ApplicationError } | { - isProcessed: true - isPaid: false + type: "markProcessedAsCanceledOrExpired" reason: ProcessedReason } | { - isProcessed: false - isPaid: false - error?: ApplicationError + type: "reasonInvoiceNotPaidYet" + } + | { + type: "error" + error: ApplicationError } diff --git a/core/api/src/app/wallets/process-pending-invoice-result.ts b/core/api/src/app/wallets/process-pending-invoice-result.ts index 47fc1258d3..d031821028 100644 --- a/core/api/src/app/wallets/process-pending-invoice-result.ts +++ b/core/api/src/app/wallets/process-pending-invoice-result.ts @@ -1,34 +1,34 @@ export const ProcessedReason = { InvoiceNotFound: "InvoiceNotFound", InvoiceCanceled: "InvoiceCanceled", + InvoiceNotFoundOrCanceled: "InvoiceNotFoundOrCanceled", +} as const + +export const ProcessPendingInvoiceResultType = { + MarkProcessedAsPaidWithError: "markProcessedAsPaidWithError", + MarkProcessedAsPaid: "markProcessedAsPaid", + MarkProcessedAsCanceledOrExpired: "markProcessedAsCanceledOrExpired", + ReasonInvoiceNotPaidYet: "reasonInvoiceNotPaidYet", + Error: "error", } as const export const ProcessPendingInvoiceResult = { - ok: (): ProcessPendingInvoiceResult => ({ - isProcessed: true, - isPaid: true, - }), - paidOnly: (): ProcessPendingInvoiceResult => ({ - isProcessed: false, - isPaid: true, + processAsPaid: (): ProcessPendingInvoiceResult => ({ + type: ProcessPendingInvoiceResultType.MarkProcessedAsPaid, }), - paidWithError: (error: ApplicationError): ProcessPendingInvoiceResult => ({ - isProcessed: false, - isPaid: true, + processAsPaidWithError: (error: ApplicationError): ProcessPendingInvoiceResult => ({ + type: ProcessPendingInvoiceResultType.MarkProcessedAsPaidWithError, error, }), - processedOnly: (reason: ProcessedReason): ProcessPendingInvoiceResult => ({ - isProcessed: true, - isPaid: false, + processAsCanceledOrExpired: (reason: ProcessedReason): ProcessPendingInvoiceResult => ({ + type: ProcessPendingInvoiceResultType.MarkProcessedAsCanceledOrExpired, reason, }), notPaid: (): ProcessPendingInvoiceResult => ({ - isProcessed: false, - isPaid: false, + type: ProcessPendingInvoiceResultType.ReasonInvoiceNotPaidYet, }), err: (error: ApplicationError): ProcessPendingInvoiceResult => ({ - isProcessed: false, - isPaid: false, + type: ProcessPendingInvoiceResultType.Error, error, }), } diff --git a/core/api/src/app/wallets/update-single-pending-invoice.ts b/core/api/src/app/wallets/update-single-pending-invoice.ts index 860ff3cc00..4e2992639f 100644 --- a/core/api/src/app/wallets/update-single-pending-invoice.ts +++ b/core/api/src/app/wallets/update-single-pending-invoice.ts @@ -4,6 +4,7 @@ import { processPendingInvoiceForDecline } from "./decline-single-pending-invoic import { ProcessPendingInvoiceResult, + ProcessPendingInvoiceResultType, ProcessedReason, } from "./process-pending-invoice-result" @@ -14,7 +15,7 @@ import { CouldNotFindError, CouldNotFindWalletInvoiceError } from "@/domain/erro import { checkedToSats } from "@/domain/bitcoin" import { DisplayAmountsConverter } from "@/domain/fiat" import { InvoiceNotFoundError } from "@/domain/bitcoin/lightning" -import { paymentAmountFromNumber, WalletCurrency } from "@/domain/shared" +import { ErrorLevel, paymentAmountFromNumber, WalletCurrency } from "@/domain/shared" import { WalletInvoiceReceiver } from "@/domain/wallet-invoices/wallet-invoice-receiver" import { DeviceTokensNotRegisteredNotificationsServiceError } from "@/domain/notifications" @@ -38,66 +39,68 @@ import { NotificationsService } from "@/services/notifications" import { toDisplayBaseAmount } from "@/domain/payments" import { LockServiceError } from "@/domain/lock" +const assertUnreachable = (x: never): never => { + throw new Error(`This should never compile with ${x}`) +} + export const updatePendingInvoice = wrapAsyncToRunInSpan({ namespace: "app.invoices", fnName: "updatePendingInvoice", fn: async ({ - walletInvoice, + walletInvoice: walletInvoiceBeforeProcessing, logger, }: { walletInvoice: WalletInvoiceWithOptionalLnInvoice logger: Logger - }): Promise => { - const walletInvoices = WalletInvoicesRepository() - const { paymentHash, recipientWalletDescriptor: recipientInvoiceWalletDescriptor } = - walletInvoice + }): Promise => { + const { paymentHash, recipientWalletDescriptor } = walletInvoiceBeforeProcessing const pendingInvoiceLogger = logger.child({ hash: paymentHash, - walletId: recipientInvoiceWalletDescriptor.id, + walletId: recipientWalletDescriptor.id, topic: "payment", protocol: "lightning", transactionType: "receipt", onUs: false, }) - let result = await processPendingInvoice({ - walletInvoice, - logger, + const result = await processPendingInvoice({ + walletInvoice: walletInvoiceBeforeProcessing, + logger: pendingInvoiceLogger, }) - if (result.isProcessed) { - const processingCompletedInvoice = - await walletInvoices.markAsProcessingCompleted(paymentHash) - if (processingCompletedInvoice instanceof Error) { - pendingInvoiceLogger.error("Unable to mark invoice as processingCompleted") - recordExceptionInCurrentSpan({ - error: processingCompletedInvoice, - level: processingCompletedInvoice.level, - }) - - result = ProcessPendingInvoiceResult.paidWithError(processingCompletedInvoice) // Marking this here temporarily to enforce status quo - } - } - - if (result.isPaid && !walletInvoice.paid) { - const invoicePaid = await walletInvoices.markAsPaid(walletInvoice.paymentHash) - if ( - invoicePaid instanceof Error && - !(invoicePaid instanceof CouldNotFindWalletInvoiceError) - ) { - return invoicePaid - } + const walletInvoices = WalletInvoicesRepository() + let marked: WalletInvoiceWithOptionalLnInvoice | RepositoryError + if (walletInvoiceBeforeProcessing.processingCompleted) return true + switch (result.type) { + case ProcessPendingInvoiceResultType.MarkProcessedAsCanceledOrExpired: + marked = await walletInvoices.markAsProcessingCompleted(paymentHash) + if (marked instanceof Error) { + pendingInvoiceLogger.error("Unable to mark invoice as processingCompleted") + return marked + } + return true + + case ProcessPendingInvoiceResultType.MarkProcessedAsPaid: + case ProcessPendingInvoiceResultType.MarkProcessedAsPaidWithError: + marked = await walletInvoices.markAsPaid(paymentHash) + if ( + marked instanceof Error && + !(marked instanceof CouldNotFindWalletInvoiceError) + ) { + return marked + } + return "error" in result ? result.error : true + + case ProcessPendingInvoiceResultType.Error: + return result.error + + case ProcessPendingInvoiceResultType.ReasonInvoiceNotPaidYet: + return true + + default: + return assertUnreachable(result) } - - const error = "error" in result && result.error - return !(result.isPaid || result.isProcessed) - ? false - : result.isProcessed - ? false - : error - ? error - : result.isPaid }, }) @@ -123,29 +126,36 @@ const processPendingInvoice = async ({ const lndService = LndService() if (lndService instanceof Error) { pendingInvoiceLogger.error("Unable to initialize LndService") - recordExceptionInCurrentSpan({ error: lndService }) return ProcessPendingInvoiceResult.err(lndService) } const lnInvoiceLookup = await lndService.lookupInvoice({ pubkey, paymentHash }) if (lnInvoiceLookup instanceof InvoiceNotFoundError) { - return ProcessPendingInvoiceResult.processedOnly(ProcessedReason.InvoiceNotFound) + return ProcessPendingInvoiceResult.processAsCanceledOrExpired( + ProcessedReason.InvoiceNotFound, + ) } if (lnInvoiceLookup instanceof Error) { - return ProcessPendingInvoiceResult.paidWithError(lnInvoiceLookup) + return ProcessPendingInvoiceResult.err(lnInvoiceLookup) } - // Check paid after invoice has been successfully fetched - if (walletInvoice.paid) { + // Check processed on wallet invoice after lnd invoice has been successfully fetched + if (walletInvoice.processingCompleted) { pendingInvoiceLogger.info("invoice has already been processed") - return ProcessPendingInvoiceResult.paidOnly() + return walletInvoice.paid + ? ProcessPendingInvoiceResult.processAsPaid() + : ProcessPendingInvoiceResult.processAsCanceledOrExpired( + ProcessedReason.InvoiceNotFoundOrCanceled, + ) } // Check status of invoice fetched from lnd service const { isCanceled, isHeld, isSettled } = lnInvoiceLookup if (isCanceled) { pendingInvoiceLogger.info("invoice has been canceled") - return ProcessPendingInvoiceResult.processedOnly(ProcessedReason.InvoiceCanceled) + return ProcessPendingInvoiceResult.processAsCanceledOrExpired( + ProcessedReason.InvoiceCanceled, + ) } if (!isHeld && !isSettled) { pendingInvoiceLogger.info("invoice has not been paid yet") @@ -161,7 +171,6 @@ const processPendingInvoice = async ({ if (roundedDownReceived instanceof Error) { recordExceptionInCurrentSpan({ error: roundedDownReceived, - level: roundedDownReceived.level, }) return processPendingInvoiceForDecline({ walletInvoice, @@ -173,7 +182,13 @@ const processPendingInvoice = async ({ currency: WalletCurrency.Btc, }) if (receivedBtc instanceof Error) { - return ProcessPendingInvoiceResult.paidWithError(receivedBtc) + recordExceptionInCurrentSpan({ + error: receivedBtc, + }) + return processPendingInvoiceForDecline({ + walletInvoice, + logger: pendingInvoiceLogger, + }) } // Continue in lock @@ -198,42 +213,49 @@ const lockedUpdatePendingInvoiceSteps = async ({ recipientWalletId, receivedBtc, description, - isSettledInLnd, logger, + + // Passed in to lock for idempotent "settle" conditional operation + isSettledInLnd, }: { paymentHash: PaymentHash recipientWalletId: WalletId receivedBtc: BtcPaymentAmount description: string - isSettledInLnd: boolean logger: Logger + + isSettledInLnd: boolean }): Promise => { + // Refetch wallet invoice const walletInvoices = WalletInvoicesRepository() - const walletInvoiceInsideLock = await walletInvoices.findByPaymentHash(paymentHash) if (walletInvoiceInsideLock instanceof CouldNotFindError) { logger.error({ paymentHash }, "WalletInvoice doesn't exist") return ProcessPendingInvoiceResult.err(walletInvoiceInsideLock) } if (walletInvoiceInsideLock instanceof Error) { - return ProcessPendingInvoiceResult.paidWithError(walletInvoiceInsideLock) + return ProcessPendingInvoiceResult.err(walletInvoiceInsideLock) } - if (walletInvoiceInsideLock.paid) { + if (walletInvoiceInsideLock.processingCompleted) { logger.info("invoice has already been processed") - return ProcessPendingInvoiceResult.paidOnly() + return walletInvoiceInsideLock.paid + ? ProcessPendingInvoiceResult.processAsPaid() + : ProcessPendingInvoiceResult.processAsCanceledOrExpired( + ProcessedReason.InvoiceNotFoundOrCanceled, + ) } - // Prepare metadata and record transaction + // Prepare ledger transaction metadata const recipientInvoiceWallet = await WalletsRepository().findById(recipientWalletId) if (recipientInvoiceWallet instanceof Error) { - return ProcessPendingInvoiceResult.paidWithError(recipientInvoiceWallet) + return ProcessPendingInvoiceResult.err(recipientInvoiceWallet) } const { accountId: recipientAccountId } = recipientInvoiceWallet const accountWallets = await WalletsRepository().findAccountWalletsByAccountId(recipientAccountId) if (accountWallets instanceof Error) { - return ProcessPendingInvoiceResult.paidWithError(accountWallets) + return ProcessPendingInvoiceResult.err(accountWallets) } const receivedWalletInvoice = await WalletInvoiceReceiver({ @@ -245,7 +267,7 @@ const lockedUpdatePendingInvoiceSteps = async ({ hedgeBuyUsd: { usdFromBtc: DealerPriceService().getCentsFromSatsForImmediateBuy }, }) if (receivedWalletInvoice instanceof Error) { - return ProcessPendingInvoiceResult.paidWithError(receivedWalletInvoice) + return ProcessPendingInvoiceResult.err(receivedWalletInvoice) } const { @@ -262,16 +284,15 @@ const lockedUpdatePendingInvoiceSteps = async ({ const recipientAccount = await AccountsRepository().findById(recipientAccountId) if (recipientAccount instanceof Error) { - return ProcessPendingInvoiceResult.paidWithError(recipientAccount) + return ProcessPendingInvoiceResult.err(recipientAccount) } const { displayCurrency: recipientDisplayCurrency } = recipientAccount const displayPriceRatio = await getCurrentPriceAsDisplayPriceRatio({ currency: recipientDisplayCurrency, }) if (displayPriceRatio instanceof Error) { - return ProcessPendingInvoiceResult.paidWithError(displayPriceRatio) + return ProcessPendingInvoiceResult.err(displayPriceRatio) } - const { displayAmount: displayPaymentAmount, displayFee } = DisplayAmountsConverter( displayPriceRatio, ).convert({ @@ -285,7 +306,6 @@ const lockedUpdatePendingInvoiceSteps = async ({ // markAsPaid could be done after the transaction, but we should in that case not only look // for walletInvoicesRepo, but also in the ledger to make sure in case the process crash in this // loop that an eventual consistency doesn't lead to a double credit - const { metadata, creditAccountAdditionalMetadata, @@ -305,30 +325,30 @@ const lockedUpdatePendingInvoiceSteps = async ({ displayCurrency: recipientDisplayCurrency, }) + // Idempotent settle invoice in lnd if (!isSettledInLnd) { const lndService = LndService() if (lndService instanceof Error) { logger.error("Unable to initialize LndService") - recordExceptionInCurrentSpan({ error: lndService }) return ProcessPendingInvoiceResult.err(lndService) } + // Returns 'true' on re-runs if invoice is already settled in lnd const invoiceSettled = await lndService.settleInvoice({ pubkey: walletInvoiceInsideLock.pubkey, secret: walletInvoiceInsideLock.secret, }) if (invoiceSettled instanceof Error) { logger.error({ paymentHash }, "Unable to settleInvoice") - recordExceptionInCurrentSpan({ error: invoiceSettled }) return ProcessPendingInvoiceResult.err(invoiceSettled) } } + // Mark paid and record ledger transaction const invoicePaid = await walletInvoices.markAsPaid(paymentHash) if (invoicePaid instanceof Error) { - return ProcessPendingInvoiceResult.paidWithError(invoicePaid) + return ProcessPendingInvoiceResult.err(invoicePaid) } - //TODO: add displayCurrency: displayPaymentAmount.currency, const journal = await LedgerFacade.recordReceiveOffChain({ description, recipientWalletDescriptor, @@ -348,13 +368,25 @@ const lockedUpdatePendingInvoiceSteps = async ({ additionalInternalMetadata: internalAccountsAdditionalMetadata, }) if (journal instanceof Error) { - return ProcessPendingInvoiceResult.paidWithError(journal) + recordExceptionInCurrentSpan({ + error: journal, + level: ErrorLevel.Critical, + attributes: { + ["error.actionRequired.message"]: + "Check that invoice exists and is settled in lnd, confirm that receipt ledger transaction " + + "did not get recorded, and finally unmark paid (and processed) from wallet invoice in " + + "wallet_invoices collection.", + ["error.actionRequired.paymentHash"]: paymentHash, + ["error.actionRequired.pubkey]"]: walletInvoiceInsideLock.pubkey, + }, + }) + return ProcessPendingInvoiceResult.err(journal) } // Prepare and send notification const recipientUser = await UsersRepository().findById(recipientAccount.kratosUserId) if (recipientUser instanceof Error) { - return ProcessPendingInvoiceResult.paidWithError(recipientUser) + return ProcessPendingInvoiceResult.processAsPaidWithError(recipientUser) } const walletTransaction = await getTransactionForWalletByJournalId({ @@ -362,7 +394,7 @@ const lockedUpdatePendingInvoiceSteps = async ({ journalId: journal.journalId, }) if (walletTransaction instanceof Error) { - return ProcessPendingInvoiceResult.paidWithError(walletTransaction) + return ProcessPendingInvoiceResult.processAsPaidWithError(walletTransaction) } const result = await NotificationsService().sendTransaction({ @@ -382,5 +414,5 @@ const lockedUpdatePendingInvoiceSteps = async ({ }) } - return ProcessPendingInvoiceResult.paidOnly() + return ProcessPendingInvoiceResult.processAsPaid() } diff --git a/core/api/src/servers/graphql-admin-api-server.ts b/core/api/src/servers/graphql-admin-api-server.ts index 3054449213..007c349217 100644 --- a/core/api/src/servers/graphql-admin-api-server.ts +++ b/core/api/src/servers/graphql-admin-api-server.ts @@ -41,7 +41,6 @@ const setGqlAdminContext = async ( if (txnMetadata instanceof Error) { recordExceptionInCurrentSpan({ error: txnMetadata, - level: txnMetadata.level, }) return keys.map(() => undefined) diff --git a/core/api/src/servers/middlewares/session.ts b/core/api/src/servers/middlewares/session.ts index cd51bf6875..8cda27eb1f 100644 --- a/core/api/src/servers/middlewares/session.ts +++ b/core/api/src/servers/middlewares/session.ts @@ -98,7 +98,6 @@ const loaders = { if (txnMetadata instanceof Error) { recordExceptionInCurrentSpan({ error: txnMetadata, - level: txnMetadata.level, }) return keys.map(() => undefined)