Skip to content

Commit

Permalink
chore(backend): estimate ILP quote receive amount, and fail early (#2783
Browse files Browse the repository at this point in the history
)

* 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
  • Loading branch information
mkurapov authored Jun 26, 2024
1 parent a8096ab commit 48a286b
Show file tree
Hide file tree
Showing 7 changed files with 133 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions packages/backend/src/open_payments/quote/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: {
Expand All @@ -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: {
Expand All @@ -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'
}
34 changes: 32 additions & 2 deletions packages/backend/src/open_payments/quote/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppServices>
Expand Down Expand Up @@ -466,6 +470,32 @@ describe('QuoteService', (): void => {
).resolves.toEqual(QuoteError.InvalidReceiver)
})

test('fails on non-positive receive amount from quote', async (): Promise<void> => {
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'}
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -711,7 +741,7 @@ describe('QuoteService', (): void => {
},
method: 'ilp'
})
).resolves.toEqual(QuoteError.NegativeReceiveAmount)
).resolves.toEqual(QuoteError.NonPositiveReceiveAmount)
})
})
})
Expand Down
14 changes: 13 additions & 1 deletion packages/backend/src/open_payments/quote/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 7 additions & 0 deletions packages/backend/src/payment-method/handler/errors.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
43 changes: 41 additions & 2 deletions packages/backend/src/payment-method/ilp/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand All @@ -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<void> => {
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()
Expand Down
36 changes: 34 additions & 2 deletions packages/backend/src/payment-method/ilp/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}

Expand Down Expand Up @@ -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
})
}

Expand Down

0 comments on commit 48a286b

Please sign in to comment.