From 48a286b09ae47b6a54a211870cb7a77f9d5d4e77 Mon Sep 17 00:00:00 2001 From: Max Kurapov Date: Wed, 26 Jun 2024 14:25:46 +0200 Subject: [PATCH] chore(backend): estimate ILP quote receive amount, and fail early (#2783) * chore(backend): check for estimated receive amount when getting an ilp quote * chore(backend): handle ilp quoting error during quote creation * chore(backend): updating error handling --- .../open_payments/payment/outgoing/errors.ts | 3 +- .../backend/src/open_payments/quote/errors.ts | 8 ++-- .../src/open_payments/quote/service.test.ts | 34 ++++++++++++++- .../src/open_payments/quote/service.ts | 14 +++++- .../src/payment-method/handler/errors.ts | 7 +++ .../src/payment-method/ilp/service.test.ts | 43 ++++++++++++++++++- .../backend/src/payment-method/ilp/service.ts | 36 +++++++++++++++- 7 files changed, 133 insertions(+), 12 deletions(-) diff --git a/packages/backend/src/open_payments/payment/outgoing/errors.ts b/packages/backend/src/open_payments/payment/outgoing/errors.ts index 3537daeb01..b8b6945a84 100644 --- a/packages/backend/src/open_payments/payment/outgoing/errors.ts +++ b/packages/backend/src/open_payments/payment/outgoing/errors.ts @@ -29,7 +29,8 @@ export const quoteErrorToOutgoingPaymentError: Record< [QuoteError.InvalidReceiver]: OutgoingPaymentError.InvalidReceiver, [QuoteError.InactiveWalletAddress]: OutgoingPaymentError.InactiveWalletAddress, - [QuoteError.NegativeReceiveAmount]: OutgoingPaymentError.NegativeReceiveAmount + [QuoteError.NonPositiveReceiveAmount]: + OutgoingPaymentError.NegativeReceiveAmount } // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types diff --git a/packages/backend/src/open_payments/quote/errors.ts b/packages/backend/src/open_payments/quote/errors.ts index 2e624ead9f..fda4b1e5bd 100644 --- a/packages/backend/src/open_payments/quote/errors.ts +++ b/packages/backend/src/open_payments/quote/errors.ts @@ -5,7 +5,7 @@ export enum QuoteError { InvalidAmount = 'InvalidAmount', InvalidReceiver = 'InvalidReceiver', InactiveWalletAddress = 'InactiveWalletAddress', - NegativeReceiveAmount = 'NegativeReceiveAmount' + NonPositiveReceiveAmount = 'NonPositiveReceiveAmount' } // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types @@ -19,7 +19,7 @@ export const errorToHTTPCode: { [QuoteError.InvalidAmount]: 400, [QuoteError.InvalidReceiver]: 400, [QuoteError.InactiveWalletAddress]: 400, - [QuoteError.NegativeReceiveAmount]: 400 + [QuoteError.NonPositiveReceiveAmount]: 400 } export const errorToCode: { @@ -29,7 +29,7 @@ export const errorToCode: { [QuoteError.InvalidAmount]: GraphQLErrorCode.BadUserInput, [QuoteError.InvalidReceiver]: GraphQLErrorCode.BadUserInput, [QuoteError.InactiveWalletAddress]: GraphQLErrorCode.Inactive, - [QuoteError.NegativeReceiveAmount]: GraphQLErrorCode.BadUserInput + [QuoteError.NonPositiveReceiveAmount]: GraphQLErrorCode.BadUserInput } export const errorToMessage: { @@ -39,5 +39,5 @@ export const errorToMessage: { [QuoteError.InvalidAmount]: 'invalid amount', [QuoteError.InvalidReceiver]: 'invalid receiver', [QuoteError.InactiveWalletAddress]: 'inactive wallet address', - [QuoteError.NegativeReceiveAmount]: 'negative receive amount' + [QuoteError.NonPositiveReceiveAmount]: 'non-positive receive amount' } diff --git a/packages/backend/src/open_payments/quote/service.test.ts b/packages/backend/src/open_payments/quote/service.test.ts index b26e20f2b7..4bddd700f5 100644 --- a/packages/backend/src/open_payments/quote/service.test.ts +++ b/packages/backend/src/open_payments/quote/service.test.ts @@ -31,6 +31,10 @@ import { PaymentMethodHandlerService } from '../../payment-method/handler/servic import { ReceiverService } from '../receiver/service' import { createReceiver } from '../../tests/receiver' import * as Pay from '@interledger/pay' +import { + PaymentMethodHandlerError, + PaymentMethodHandlerErrorCode +} from '../../payment-method/handler/errors' describe('QuoteService', (): void => { let deps: IocContract @@ -466,6 +470,32 @@ describe('QuoteService', (): void => { ).resolves.toEqual(QuoteError.InvalidReceiver) }) + test('fails on non-positive receive amount from quote', async (): Promise => { + const receiver = await createReceiver(deps, receivingWalletAddress) + + jest + .spyOn(paymentMethodHandlerService, 'getQuote') + .mockImplementationOnce(() => { + throw new PaymentMethodHandlerError('Failed getting quote', { + code: PaymentMethodHandlerErrorCode.QuoteNonPositiveReceiveAmount, + description: 'Non positive receive amount for quote' + }) + }) + + await expect( + quoteService.create({ + walletAddressId: sendingWalletAddress.id, + receiver: receiver.incomingPayment!.id, + method: 'ilp', + debitAmount: { + value: 2n, + assetCode: sendingWalletAddress.asset.code, + assetScale: sendingWalletAddress.asset.scale + } + }) + ).resolves.toBe(QuoteError.NonPositiveReceiveAmount) + }) + test.each` debitAmount | receiveAmount | description ${{ ...debitAmount, value: BigInt(0) }} | ${undefined} | ${'with debitAmount of zero'} @@ -678,7 +708,7 @@ describe('QuoteService', (): void => { } ) - test('fails on negative receive amount', async () => { + test('fails on non-positive receive amount', async () => { const receiver = await createReceiver(deps, receivingWalletAddress) const debitAmountValue = 100n @@ -711,7 +741,7 @@ describe('QuoteService', (): void => { }, method: 'ilp' }) - ).resolves.toEqual(QuoteError.NegativeReceiveAmount) + ).resolves.toEqual(QuoteError.NonPositiveReceiveAmount) }) }) }) diff --git a/packages/backend/src/open_payments/quote/service.ts b/packages/backend/src/open_payments/quote/service.ts index 5d2e1795fa..04d9df7f87 100644 --- a/packages/backend/src/open_payments/quote/service.ts +++ b/packages/backend/src/open_payments/quote/service.ts @@ -16,6 +16,10 @@ import { PaymentMethodHandlerService } from '../../payment-method/handler/servic import { IAppConfig } from '../../config/app' import { FeeService } from '../../fee/service' import { FeeType } from '../../fee/model' +import { + PaymentMethodHandlerError, + PaymentMethodHandlerErrorCode +} from '../../payment-method/handler/errors' const MAX_INT64 = BigInt('9223372036854775807') @@ -159,6 +163,14 @@ async function createQuote( if (isQuoteError(err)) { return err } + + if ( + err instanceof PaymentMethodHandlerError && + err.code === PaymentMethodHandlerErrorCode.QuoteNonPositiveReceiveAmount + ) { + return QuoteError.NonPositiveReceiveAmount + } + deps.logger.error({ err }, 'error creating a quote') throw err } @@ -233,7 +245,7 @@ function calculateFixedSendQuoteAmounts( { fees, exchangeAdjustedFees, estimatedExchangeRate, receiveAmountValue }, 'Negative receive amount when calculating quote amount' ) - throw QuoteError.NegativeReceiveAmount + throw QuoteError.NonPositiveReceiveAmount } if (receiveAmountValue > maxReceiveAmountValue) { diff --git a/packages/backend/src/payment-method/handler/errors.ts b/packages/backend/src/payment-method/handler/errors.ts index ba218679e6..e07dc3671b 100644 --- a/packages/backend/src/payment-method/handler/errors.ts +++ b/packages/backend/src/payment-method/handler/errors.ts @@ -1,16 +1,23 @@ interface ErrorDetails { description: string retryable?: boolean + code?: PaymentMethodHandlerErrorCode +} + +export enum PaymentMethodHandlerErrorCode { + QuoteNonPositiveReceiveAmount = 'QuoteNonPositiveReceiveAmount' } export class PaymentMethodHandlerError extends Error { public description: string public retryable?: boolean + public code?: PaymentMethodHandlerErrorCode constructor(message: string, args: ErrorDetails) { super(message) this.name = 'PaymentMethodHandlerError' this.description = args.description this.retryable = args.retryable + this.code = args.code } } diff --git a/packages/backend/src/payment-method/ilp/service.test.ts b/packages/backend/src/payment-method/ilp/service.test.ts index 728a9e5b12..1088abc2e0 100644 --- a/packages/backend/src/payment-method/ilp/service.test.ts +++ b/packages/backend/src/payment-method/ilp/service.test.ts @@ -14,7 +14,10 @@ import * as Pay from '@interledger/pay' import { createReceiver } from '../../tests/receiver' import { mockRatesApi } from '../../tests/rates' -import { PaymentMethodHandlerError } from '../handler/errors' +import { + PaymentMethodHandlerError, + PaymentMethodHandlerErrorCode +} from '../handler/errors' import { OutgoingPayment } from '../../open_payments/payment/outgoing/model' import { AccountingService } from '../../accounting/service' import { IncomingPayment } from '../../open_payments/payment/incoming/model' @@ -284,7 +287,7 @@ describe('IlpPaymentService', (): void => { minDeliveryAmount: -1n } as Pay.Quote) - expect.assertions(4) + expect.assertions(5) try { await ilpPaymentService.getQuote(options) } catch (error) { @@ -296,6 +299,42 @@ describe('IlpPaymentService', (): void => { 'Minimum delivery amount of ILP quote is non-positive' ) expect((error as PaymentMethodHandlerError).retryable).toBe(false) + expect((error as PaymentMethodHandlerError).code).toBe( + PaymentMethodHandlerErrorCode.QuoteNonPositiveReceiveAmount + ) + } + + ratesScope.done() + }) + + test('throws if quote returns with a non-positive estimated delivery amount', async (): Promise => { + const ratesScope = mockRatesApi(exchangeRatesUrl, () => ({})) + + const options: StartQuoteOptions = { + walletAddress: walletAddressMap['USD'], + receiver: await createReceiver(deps, walletAddressMap['USD']) + } + + jest.spyOn(Pay, 'startQuote').mockResolvedValueOnce({ + maxSourceAmount: 10n, + highEstimatedExchangeRate: Pay.Ratio.from(0.099) + } as Pay.Quote) + + expect.assertions(5) + try { + await ilpPaymentService.getQuote(options) + } catch (error) { + expect(error).toBeInstanceOf(PaymentMethodHandlerError) + expect((error as PaymentMethodHandlerError).message).toBe( + 'Received error during ILP quoting' + ) + expect((error as PaymentMethodHandlerError).description).toBe( + 'Estimated receive amount of ILP quote is non-positive' + ) + expect((error as PaymentMethodHandlerError).retryable).toBe(false) + expect((error as PaymentMethodHandlerError).code).toBe( + PaymentMethodHandlerErrorCode.QuoteNonPositiveReceiveAmount + ) } ratesScope.done() diff --git a/packages/backend/src/payment-method/ilp/service.ts b/packages/backend/src/payment-method/ilp/service.ts index eff9133541..94299ae90a 100644 --- a/packages/backend/src/payment-method/ilp/service.ts +++ b/packages/backend/src/payment-method/ilp/service.ts @@ -10,7 +10,10 @@ import { IlpPlugin, IlpPluginOptions } from './ilp_plugin' import * as Pay from '@interledger/pay' import { convertRatesToIlpPrices } from './rates' import { IAppConfig } from '../../config/app' -import { PaymentMethodHandlerError } from '../handler/errors' +import { + PaymentMethodHandlerError, + PaymentMethodHandlerErrorCode +} from '../handler/errors' export interface IlpPaymentService extends PaymentMethodService {} @@ -100,7 +103,36 @@ async function getQuote( if (ilpQuote.minDeliveryAmount <= BigInt(0)) { throw new PaymentMethodHandlerError('Received error during ILP quoting', { description: 'Minimum delivery amount of ILP quote is non-positive', - retryable: false + retryable: false, + code: PaymentMethodHandlerErrorCode.QuoteNonPositiveReceiveAmount + }) + } + + // Because of how it does rounding, the Pay library allows getting a quote for a + // maxSourceAmount that won't be able to fulfill even a single unit of the receiving asset. + // e.g. if maxSourceAmount is 4 and the high estimated exchange rate is 0.2, 4 * 0.2 = 0.8 + // where 0.8 < 1, meaning the payment for this quote won't be able to deliver a single unit of value, + // even with the most favourable exchange rate. We throw here since we don't want any payments + // to be created against this quote. This allows us to fail early. + const estimatedReceiveAmount = + Number(ilpQuote.maxSourceAmount) * + ilpQuote.highEstimatedExchangeRate.valueOf() + + if (estimatedReceiveAmount < 1) { + const errorDescription = + 'Estimated receive amount of ILP quote is non-positive' + deps.logger.debug( + { + minDeliveryAmount: ilpQuote.minDeliveryAmount, + maxSourceAmount: ilpQuote.maxSourceAmount, + estimatedReceiveAmount + }, + errorDescription + ) + throw new PaymentMethodHandlerError('Received error during ILP quoting', { + description: errorDescription, + retryable: false, + code: PaymentMethodHandlerErrorCode.QuoteNonPositiveReceiveAmount }) }