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 77981c5ed4d..90297d1ff4a 100644 --- a/core/api/src/app/wallets/decline-single-pending-invoice.ts +++ b/core/api/src/app/wallets/decline-single-pending-invoice.ts @@ -1,4 +1,9 @@ -import { InvoiceNotFoundError } from "@/domain/bitcoin/lightning" +import { ProcessPendingInvoiceResult } from "./process-pending-invoice-result" + +import { + InvoiceNotFoundError, + InvoiceNotPaidOnLndError, +} from "@/domain/bitcoin/lightning" import { InvalidNonHodlInvoiceError } from "@/domain/errors" @@ -18,66 +23,82 @@ export const declineHeldInvoice = wrapAsyncToRunInSpan({ walletInvoice: WalletInvoiceWithOptionalLnInvoice logger: Logger }): Promise => { - const { pubkey, paymentHash } = walletInvoice - addAttributesToCurrentSpan({ paymentHash, pubkey }) - - const lndService = LndService() - if (lndService instanceof Error) return lndService - - const walletInvoicesRepo = WalletInvoicesRepository() - - const lnInvoiceLookup = await lndService.lookupInvoice({ pubkey, paymentHash }) - + const { paymentHash, pubkey } = walletInvoice const pendingInvoiceLogger = logger.child({ hash: paymentHash, pubkey, - lnInvoiceLookup, topic: "payment", protocol: "lightning", transactionType: "receipt", onUs: false, }) - if (lnInvoiceLookup instanceof InvoiceNotFoundError) { + const result = await processPendingInvoice({ + walletInvoice, + logger: pendingInvoiceLogger, + }) + + if (result.isProcessed) { const processingCompletedInvoice = - await walletInvoicesRepo.markAsProcessingCompleted(paymentHash) + await WalletInvoicesRepository().markAsProcessingCompleted(paymentHash) if (processingCompletedInvoice instanceof Error) { pendingInvoiceLogger.error("Unable to mark invoice as processingCompleted") return processingCompletedInvoice } - return false } - if (lnInvoiceLookup instanceof Error) return lnInvoiceLookup - if (lnInvoiceLookup.isSettled) { - return new InvalidNonHodlInvoiceError( - JSON.stringify({ paymentHash: lnInvoiceLookup.paymentHash }), - ) - } + return true + }, +}) - if (!lnInvoiceLookup.isHeld) { - pendingInvoiceLogger.info({ lnInvoiceLookup }, "invoice has not been paid yet") - return false - } +const processPendingInvoice = async ({ + walletInvoice, + logger: pendingInvoiceLogger, +}: { + walletInvoice: WalletInvoiceWithOptionalLnInvoice + logger: Logger +}): Promise => { + const { pubkey, paymentHash } = walletInvoice + addAttributesToCurrentSpan({ paymentHash, pubkey }) - let heldForMsg = "" - if (lnInvoiceLookup.heldAt) { - heldForMsg = `for ${elapsedSinceTimestamp(lnInvoiceLookup.heldAt)}s ` - } - pendingInvoiceLogger.error( - { lnInvoiceLookup }, - `invoice has been held ${heldForMsg}and is now been cancelled`, + const lndService = LndService() + if (lndService instanceof Error) return ProcessPendingInvoiceResult.paidOnly(lndService) + + const lnInvoiceLookup = await lndService.lookupInvoice({ pubkey, paymentHash }) + + if (lnInvoiceLookup instanceof InvoiceNotFoundError) { + return ProcessPendingInvoiceResult.err(lnInvoiceLookup) + } + if (lnInvoiceLookup instanceof Error) { + return ProcessPendingInvoiceResult.paidOnly(lnInvoiceLookup) + } + + if (lnInvoiceLookup.isSettled) { + return ProcessPendingInvoiceResult.paidOnly( + new InvalidNonHodlInvoiceError( + JSON.stringify({ paymentHash: lnInvoiceLookup.paymentHash }), + ), ) + } - const invoiceSettled = await lndService.cancelInvoice({ pubkey, paymentHash }) - if (invoiceSettled instanceof Error) return invoiceSettled + if (!lnInvoiceLookup.isHeld) { + pendingInvoiceLogger.info({ lnInvoiceLookup }, "invoice has not been paid yet") + ProcessPendingInvoiceResult.err(new InvoiceNotPaidOnLndError()) + } - const processingCompletedInvoice = - await walletInvoicesRepo.markAsProcessingCompleted(paymentHash) - if (processingCompletedInvoice instanceof Error) { - pendingInvoiceLogger.error("Unable to mark invoice as processingCompleted") - } + let heldForMsg = "" + if (lnInvoiceLookup.heldAt) { + heldForMsg = `for ${elapsedSinceTimestamp(lnInvoiceLookup.heldAt)}s ` + } + pendingInvoiceLogger.error( + { lnInvoiceLookup }, + `invoice has been held ${heldForMsg}and is now been cancelled`, + ) - return true - }, -}) + const invoiceSettled = await lndService.cancelInvoice({ pubkey, paymentHash }) + if (invoiceSettled instanceof Error) { + return ProcessPendingInvoiceResult.paidOnly(invoiceSettled) + } + + return ProcessPendingInvoiceResult.ok() +} diff --git a/core/api/src/app/wallets/index.types.d.ts b/core/api/src/app/wallets/index.types.d.ts index ea32ac09d65..926568374b8 100644 --- a/core/api/src/app/wallets/index.types.d.ts +++ b/core/api/src/app/wallets/index.types.d.ts @@ -131,3 +131,10 @@ type LnurlPaymentSendArgs = { lnurl: string amount: number } + +type ProcessPendingInvoiceResult = { + isPaid: boolean + isProcessed: boolean + isDeclined: boolean + error: ApplicationError | undefined +} diff --git a/core/api/src/app/wallets/process-pending-invoice-result.ts b/core/api/src/app/wallets/process-pending-invoice-result.ts new file mode 100644 index 00000000000..4c76baf6a56 --- /dev/null +++ b/core/api/src/app/wallets/process-pending-invoice-result.ts @@ -0,0 +1,32 @@ +export const ProcessPendingInvoiceResult = { + ok: (): ProcessPendingInvoiceResult => ({ + isProcessed: true, + isPaid: true, + isDeclined: false, + error: undefined, + }), + paidOnly: (error: ApplicationError): ProcessPendingInvoiceResult => ({ + isProcessed: false, + isPaid: true, + isDeclined: false, + error, + }), + processedOnly: (error: ApplicationError): ProcessPendingInvoiceResult => ({ + isProcessed: true, + isPaid: false, + isDeclined: false, + error, + }), + declineOnly: (error: ApplicationError): ProcessPendingInvoiceResult => ({ + isProcessed: false, + isPaid: false, + isDeclined: true, + error, + }), + err: (error: ApplicationError): ProcessPendingInvoiceResult => ({ + isProcessed: false, + isPaid: false, + isDeclined: false, + 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 c2b10777d37..6d3cae637cc 100644 --- a/core/api/src/app/wallets/update-single-pending-invoice.ts +++ b/core/api/src/app/wallets/update-single-pending-invoice.ts @@ -2,13 +2,19 @@ import { getTransactionForWalletByJournalId } from "./get-transaction-by-journal import { declineHeldInvoice } from "./decline-single-pending-invoice" +import { ProcessPendingInvoiceResult } from "./process-pending-invoice-result" + import { removeDeviceTokens } from "@/app/users/remove-device-tokens" import { getCurrentPriceAsDisplayPriceRatio, usdFromBtcMidPriceFn } from "@/app/prices" import { CouldNotFindError, CouldNotFindWalletInvoiceError } from "@/domain/errors" import { checkedToSats } from "@/domain/bitcoin" import { DisplayAmountsConverter } from "@/domain/fiat" -import { InvoiceNotFoundError } from "@/domain/bitcoin/lightning" +import { + InvoiceCanceledOnLndError, + InvoiceNotFoundError, + InvoiceNotPaidOnLndError, +} from "@/domain/bitcoin/lightning" import { paymentAmountFromNumber, WalletCurrency } from "@/domain/shared" import { WalletInvoiceReceiver } from "@/domain/wallet-invoices/wallet-invoice-receiver" import { DeviceTokensNotRegisteredNotificationsServiceError } from "@/domain/notifications" @@ -31,6 +37,7 @@ import { DealerPriceService } from "@/services/dealer-price" import { NotificationsService } from "@/services/notifications" import { toDisplayBaseAmount } from "@/domain/payments" +import { LockServiceError } from "@/domain/lock" export const updatePendingInvoice = wrapAsyncToRunInSpan({ namespace: "app.invoices", @@ -42,133 +49,142 @@ export const updatePendingInvoice = wrapAsyncToRunInSpan({ walletInvoice: WalletInvoiceWithOptionalLnInvoice logger: Logger }): Promise => { - const result = await updatePendingInvoiceBeforeFinally({ + const walletInvoices = WalletInvoicesRepository() + const { paymentHash, recipientWalletDescriptor: recipientInvoiceWalletDescriptor } = + walletInvoice + + const pendingInvoiceLogger = logger.child({ + hash: paymentHash, + walletId: recipientInvoiceWalletDescriptor.id, + topic: "payment", + protocol: "lightning", + transactionType: "receipt", + onUs: false, + }) + + const result = await processPendingInvoice({ walletInvoice, logger, }) - if (result) { - if (!walletInvoice.paid) { - const walletInvoices = WalletInvoicesRepository() - const invoicePaid = await walletInvoices.markAsPaid(walletInvoice.paymentHash) - if ( - invoicePaid instanceof Error && - !(invoicePaid instanceof CouldNotFindWalletInvoiceError) - ) { - return invoicePaid - } + if (result.isDeclined) { + return declineHeldInvoice({ + walletInvoice, + logger, + }) + } + + if (result.isProcessed) { + const processingCompletedInvoice = + await walletInvoices.markAsProcessingCompleted(paymentHash) + if (processingCompletedInvoice instanceof Error) { + pendingInvoiceLogger.error("Unable to mark invoice as processingCompleted") + return processingCompletedInvoice } } - return result + + if (result.isPaid) { + if (walletInvoice.paid) return true + + const invoicePaid = await walletInvoices.markAsPaid(walletInvoice.paymentHash) + if ( + invoicePaid instanceof Error && + !(invoicePaid instanceof CouldNotFindWalletInvoiceError) + ) { + return invoicePaid + } + } + + return true }, }) -const updatePendingInvoiceBeforeFinally = async ({ +const processPendingInvoice = async ({ walletInvoice, - logger, + logger: pendingInvoiceLogger, }: { walletInvoice: WalletInvoiceWithOptionalLnInvoice logger: Logger -}): Promise => { - addAttributesToCurrentSpan({ - paymentHash: walletInvoice.paymentHash, - pubkey: walletInvoice.pubkey, - }) - - const walletInvoicesRepo = WalletInvoicesRepository() - +}): Promise => { const { pubkey, paymentHash, recipientWalletDescriptor: recipientInvoiceWalletDescriptor, } = walletInvoice - addAttributesToCurrentSpan({ + paymentHash, + pubkey, "invoices.originalRecipient": JSON.stringify(recipientInvoiceWalletDescriptor), }) - const pendingInvoiceLogger = logger.child({ - hash: paymentHash, - walletId: recipientInvoiceWalletDescriptor.id, - topic: "payment", - protocol: "lightning", - transactionType: "receipt", - onUs: false, - }) - const lndService = LndService() if (lndService instanceof Error) { pendingInvoiceLogger.error("Unable to initialize LndService") recordExceptionInCurrentSpan({ error: lndService }) - return false + return ProcessPendingInvoiceResult.err(lndService) } + const lnInvoiceLookup = await lndService.lookupInvoice({ pubkey, paymentHash }) if (lnInvoiceLookup instanceof InvoiceNotFoundError) { - const processingCompletedInvoice = - await walletInvoicesRepo.markAsProcessingCompleted(paymentHash) - if (processingCompletedInvoice instanceof Error) { - pendingInvoiceLogger.error("Unable to mark invoice as processingCompleted") - return processingCompletedInvoice - } - return false + return ProcessPendingInvoiceResult.processedOnly(lnInvoiceLookup) + } + if (lnInvoiceLookup instanceof Error) { + return ProcessPendingInvoiceResult.paidOnly(lnInvoiceLookup) } - if (lnInvoiceLookup instanceof Error) return lnInvoiceLookup if (walletInvoice.paid) { pendingInvoiceLogger.info("invoice has already been processed") - return true + return ProcessPendingInvoiceResult.ok() } const { lnInvoice: { description }, roundedDownReceived: uncheckedRoundedDownReceived, + isCanceled, + isHeld, + isSettled, } = lnInvoiceLookup - if (lnInvoiceLookup.isCanceled) { + if (isCanceled) { pendingInvoiceLogger.info("invoice has been canceled") - const processingCompletedInvoice = - await walletInvoicesRepo.markAsProcessingCompleted(paymentHash) - if (processingCompletedInvoice instanceof Error) { - pendingInvoiceLogger.error("Unable to mark invoice as processingCompleted") - return processingCompletedInvoice - } - return false + return ProcessPendingInvoiceResult.processedOnly(new InvoiceCanceledOnLndError()) } - if (!lnInvoiceLookup.isHeld && !lnInvoiceLookup.isSettled) { + if (!isHeld && !isSettled) { pendingInvoiceLogger.info("invoice has not been paid yet") - return false + ProcessPendingInvoiceResult.err(new InvoiceNotPaidOnLndError()) } - // TODO: validate roundedDownReceived as user input const roundedDownReceived = checkedToSats(uncheckedRoundedDownReceived) if (roundedDownReceived instanceof Error) { recordExceptionInCurrentSpan({ error: roundedDownReceived, level: roundedDownReceived.level, }) - return declineHeldInvoice({ - walletInvoice, - logger, - }) + return ProcessPendingInvoiceResult.declineOnly(roundedDownReceived) } const receivedBtc = paymentAmountFromNumber({ amount: roundedDownReceived, currency: WalletCurrency.Btc, }) - if (receivedBtc instanceof Error) return receivedBtc + if (receivedBtc instanceof Error) { + return ProcessPendingInvoiceResult.paidOnly(receivedBtc) + } - const lockService = LockService() - return lockService.lockPaymentHash(paymentHash, async () => + const result = await LockService().lockPaymentHash(paymentHash, async () => lockedUpdatePendingInvoiceSteps({ recipientWalletId: recipientInvoiceWalletDescriptor.id, paymentHash, receivedBtc, description, isSettledInLnd: lnInvoiceLookup.isSettled, - logger, + logger: pendingInvoiceLogger, }), ) + if (result instanceof LockServiceError) { + return ProcessPendingInvoiceResult.err(result) + } + return result } const lockedUpdatePendingInvoiceSteps = async ({ @@ -185,28 +201,33 @@ const lockedUpdatePendingInvoiceSteps = async ({ description: string isSettledInLnd: boolean logger: Logger -}) => { +}): Promise => { const walletInvoices = WalletInvoicesRepository() const walletInvoiceInsideLock = await walletInvoices.findByPaymentHash(paymentHash) if (walletInvoiceInsideLock instanceof CouldNotFindError) { logger.error({ paymentHash }, "WalletInvoice doesn't exist") - return false + return ProcessPendingInvoiceResult.err(walletInvoiceInsideLock) } - if (walletInvoiceInsideLock instanceof Error) return walletInvoiceInsideLock + if (walletInvoiceInsideLock instanceof Error) + return ProcessPendingInvoiceResult.paidOnly(walletInvoiceInsideLock) if (walletInvoiceInsideLock.paid) { logger.info("invoice has already been processed") - return true + return ProcessPendingInvoiceResult.ok() } // Prepare metadata and record transaction const recipientInvoiceWallet = await WalletsRepository().findById(recipientWalletId) - if (recipientInvoiceWallet instanceof Error) return recipientInvoiceWallet + if (recipientInvoiceWallet instanceof Error) { + return ProcessPendingInvoiceResult.paidOnly(recipientInvoiceWallet) + } const { accountId: recipientAccountId } = recipientInvoiceWallet const accountWallets = await WalletsRepository().findAccountWalletsByAccountId(recipientAccountId) - if (accountWallets instanceof Error) return accountWallets + if (accountWallets instanceof Error) { + return ProcessPendingInvoiceResult.paidOnly(accountWallets) + } const receivedWalletInvoice = await WalletInvoiceReceiver({ walletInvoice: walletInvoiceInsideLock, @@ -216,7 +237,9 @@ const lockedUpdatePendingInvoiceSteps = async ({ mid: { usdFromBtc: usdFromBtcMidPriceFn }, hedgeBuyUsd: { usdFromBtc: DealerPriceService().getCentsFromSatsForImmediateBuy }, }) - if (receivedWalletInvoice instanceof Error) return receivedWalletInvoice + if (receivedWalletInvoice instanceof Error) { + return ProcessPendingInvoiceResult.paidOnly(receivedWalletInvoice) + } const { recipientWalletDescriptor, @@ -231,12 +254,16 @@ const lockedUpdatePendingInvoiceSteps = async ({ }) const recipientAccount = await AccountsRepository().findById(recipientAccountId) - if (recipientAccount instanceof Error) return recipientAccount + if (recipientAccount instanceof Error) { + return ProcessPendingInvoiceResult.paidOnly(recipientAccount) + } const { displayCurrency: recipientDisplayCurrency } = recipientAccount const displayPriceRatio = await getCurrentPriceAsDisplayPriceRatio({ currency: recipientDisplayCurrency, }) - if (displayPriceRatio instanceof Error) return displayPriceRatio + if (displayPriceRatio instanceof Error) { + return ProcessPendingInvoiceResult.paidOnly(displayPriceRatio) + } const { displayAmount: displayPaymentAmount, displayFee } = DisplayAmountsConverter( displayPriceRatio, @@ -276,7 +303,7 @@ const lockedUpdatePendingInvoiceSteps = async ({ if (lndService instanceof Error) { logger.error("Unable to initialize LndService") recordExceptionInCurrentSpan({ error: lndService }) - return false + return ProcessPendingInvoiceResult.err(lndService) } const invoiceSettled = await lndService.settleInvoice({ pubkey: walletInvoiceInsideLock.pubkey, @@ -285,12 +312,14 @@ const lockedUpdatePendingInvoiceSteps = async ({ if (invoiceSettled instanceof Error) { logger.error({ paymentHash }, "Unable to settleInvoice") recordExceptionInCurrentSpan({ error: invoiceSettled }) - return false + return ProcessPendingInvoiceResult.err(invoiceSettled) } } const invoicePaid = await walletInvoices.markAsPaid(paymentHash) - if (invoicePaid instanceof Error) return invoicePaid + if (invoicePaid instanceof Error) { + return ProcessPendingInvoiceResult.paidOnly(invoicePaid) + } //TODO: add displayCurrency: displayPaymentAmount.currency, const journal = await LedgerFacade.recordReceiveOffChain({ @@ -311,17 +340,23 @@ const lockedUpdatePendingInvoiceSteps = async ({ additionalCreditMetadata: creditAccountAdditionalMetadata, additionalInternalMetadata: internalAccountsAdditionalMetadata, }) - if (journal instanceof Error) return journal + if (journal instanceof Error) { + return ProcessPendingInvoiceResult.paidOnly(journal) + } // Prepare and send notification const recipientUser = await UsersRepository().findById(recipientAccount.kratosUserId) - if (recipientUser instanceof Error) return recipientUser + if (recipientUser instanceof Error) { + return ProcessPendingInvoiceResult.paidOnly(recipientUser) + } const walletTransaction = await getTransactionForWalletByJournalId({ walletId: recipientWalletDescriptor.id, journalId: journal.journalId, }) - if (walletTransaction instanceof Error) return walletTransaction + if (walletTransaction instanceof Error) { + return ProcessPendingInvoiceResult.paidOnly(walletTransaction) + } const result = await NotificationsService().sendTransaction({ recipient: { @@ -340,5 +375,5 @@ const lockedUpdatePendingInvoiceSteps = async ({ }) } - return true + return ProcessPendingInvoiceResult.ok() } diff --git a/core/api/src/domain/bitcoin/lightning/errors.ts b/core/api/src/domain/bitcoin/lightning/errors.ts index 63ffdb6ed48..2b88084a9fe 100644 --- a/core/api/src/domain/bitcoin/lightning/errors.ts +++ b/core/api/src/domain/bitcoin/lightning/errors.ts @@ -19,6 +19,8 @@ export class SecretDoesNotMatchAnyExistingHodlInvoiceError extends LightningServ } export class InvoiceNotFoundError extends LightningServiceError {} +export class InvoiceCanceledOnLndError extends LightningServiceError {} +export class InvoiceNotPaidOnLndError extends LightningServiceError {} export class LnPaymentPendingError extends LightningServiceError {} export class LnAlreadyPaidError extends LightningServiceError {} export class MaxFeeTooLargeForRoutelessPaymentError extends LightningServiceError { diff --git a/core/api/src/graphql/error-map.ts b/core/api/src/graphql/error-map.ts index 216d8be0f5d..c5520b98912 100644 --- a/core/api/src/graphql/error-map.ts +++ b/core/api/src/graphql/error-map.ts @@ -522,6 +522,8 @@ export const mapError = (error: ApplicationError): CustomGraphQLError => { case "LightningServiceError": case "CouldNotDecodeReturnedPaymentRequest": case "InvoiceNotFoundError": + case "InvoiceCanceledOnLndError": + case "InvoiceNotPaidOnLndError": case "InvoiceNotPaidError": case "LnPaymentPendingError": case "LnAlreadyPaidError":