Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 2 additions & 23 deletions packages/backend/src/asset/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import { Pagination, SortOrder } from '../shared/baseModel'
import { getPageTests } from '../shared/baseModel.test'
import { createTestApp, TestContainer } from '../tests/app'
import { createAsset, randomAsset } from '../tests/asset'
import { truncateTable, truncateTables } from '../tests/tableManager'
import { Config, IAppConfig } from '../config/app'
import { truncateTables } from '../tests/tableManager'
import { Config } from '../config/app'
import { IocContract } from '@adonisjs/fold'
import { initIocContainer } from '../'
import { AppServices } from '../app'
Expand All @@ -36,12 +36,10 @@ describe('Asset Service', (): void => {
let peerService: PeerService
let walletAddressService: WalletAddressService
let tenantSettingService: TenantSettingService
let config: IAppConfig

beforeAll(async (): Promise<void> => {
deps = initIocContainer(Config)
appContainer = await createTestApp(deps)
config = await deps.use('config')
assetService = await deps.use('assetService')
walletAddressService = await deps.use('walletAddressService')
tenantSettingService = await deps.use('tenantSettingService')
Expand Down Expand Up @@ -165,25 +163,6 @@ describe('Asset Service', (): void => {
)
})

test('Cannot create more than one asset if no exchange rates URL is set', async (): Promise<void> => {
await truncateTable(appContainer.knex, 'tenantSettings')
config.operatorExchangeRatesUrl = undefined
const firstAssetOptions = {
...randomAsset(),
tenantId: Config.operatorTenantId
}
await expect(
assetService.create(firstAssetOptions)
).resolves.toMatchObject(firstAssetOptions)
const secondAssetOptions = {
...randomAsset(),
tenantId: Config.operatorTenantId
}
await expect(assetService.create(secondAssetOptions)).resolves.toEqual(
AssetError.NoRatesForAsset
)
})

test('Cannot create asset with scale > 255', async (): Promise<void> => {
const options = {
code: 'ABC',
Expand Down
14 changes: 0 additions & 14 deletions packages/backend/src/asset/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { WalletAddress } from '../open_payments/wallet_address/model'
import { Peer } from '../payment-method/ilp/peer/model'
import { CacheDataStore } from '../middleware/cache/data-stores'
import { TenantSettingService } from '../tenants/settings/service'
import { TenantSettingKeys } from '../tenants/settings/model'
import { IAppConfig } from '../config/app'

export interface AssetOptions {
Expand Down Expand Up @@ -112,19 +111,6 @@ async function createAsset(
.andWhere('tenantId', tenantId)
.select('*')

const sameCodeAssets = assets.find((asset) => asset.code === code)
if (!sameCodeAssets && assets.length > 0) {
const exchangeUrlSetting = await deps.tenantSettingService.get({
tenantId,
key: TenantSettingKeys.EXCHANGE_RATES_URL.name
})

const tenantExchangeRatesUrl = exchangeUrlSetting[0]?.value
if (!tenantExchangeRatesUrl && !deps.config.operatorExchangeRatesUrl) {
return AssetError.NoRatesForAsset
}
}

const deletedAsset = assets.find(
(asset) =>
asset.deletedAt !== null && asset.code === code && asset.scale === scale
Expand Down
16 changes: 11 additions & 5 deletions packages/backend/src/open_payments/payment/outgoing/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export enum OutgoingPaymentError {
NegativeReceiveAmount = 'NegativeReceiveAmount',
InvalidReceiver = 'InvalidReceiver',
InvalidInterval = 'InvalidInterval',
OnlyOneGrantAmountAllowed = 'OnlyOneGrantAmountAllowed'
OnlyOneGrantAmountAllowed = 'OnlyOneGrantAmountAllowed',
CouldNotFetchRates = 'CouldNotFetchRates'
}

export const quoteErrorToOutgoingPaymentError: Record<
Expand All @@ -33,7 +34,8 @@ export const quoteErrorToOutgoingPaymentError: Record<
[QuoteErrorCode.InactiveWalletAddress]:
OutgoingPaymentError.InactiveWalletAddress,
[QuoteErrorCode.NonPositiveReceiveAmount]:
OutgoingPaymentError.NegativeReceiveAmount
OutgoingPaymentError.NegativeReceiveAmount,
[QuoteErrorCode.CouldNotFetchRates]: OutgoingPaymentError.CouldNotFetchRates
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
Expand All @@ -54,7 +56,8 @@ export const errorToHTTPCode: {
[OutgoingPaymentError.NegativeReceiveAmount]: 400,
[OutgoingPaymentError.InvalidReceiver]: 400,
[OutgoingPaymentError.InvalidInterval]: 500,
[OutgoingPaymentError.OnlyOneGrantAmountAllowed]: 500
[OutgoingPaymentError.OnlyOneGrantAmountAllowed]: 500,
[OutgoingPaymentError.CouldNotFetchRates]: 500
}

export const errorToCode: {
Expand All @@ -72,7 +75,9 @@ export const errorToCode: {
[OutgoingPaymentError.InvalidReceiver]: GraphQLErrorCode.BadUserInput,
[OutgoingPaymentError.InvalidInterval]: GraphQLErrorCode.InternalServerError,
[OutgoingPaymentError.OnlyOneGrantAmountAllowed]:
GraphQLErrorCode.BadUserInput
GraphQLErrorCode.BadUserInput,
[OutgoingPaymentError.CouldNotFetchRates]:
GraphQLErrorCode.InternalServerError
}

export const errorToMessage: {
Expand All @@ -90,7 +95,8 @@ export const errorToMessage: {
[OutgoingPaymentError.InvalidReceiver]: 'invalid receiver',
[OutgoingPaymentError.InvalidInterval]: 'invalid interval',
[OutgoingPaymentError.OnlyOneGrantAmountAllowed]:
'only one of receiveAmount or debitAmount allowed'
'only one of receiveAmount or debitAmount allowed',
[OutgoingPaymentError.CouldNotFetchRates]: 'could not convert between assets'
}

export const FundingError = { ...OutgoingPaymentError, ...TransferError }
Expand Down
12 changes: 8 additions & 4 deletions packages/backend/src/open_payments/quote/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export enum QuoteErrorCode {
InvalidAmount = 'InvalidAmount',
InvalidReceiver = 'InvalidReceiver',
InactiveWalletAddress = 'InactiveWalletAddress',
NonPositiveReceiveAmount = 'NonPositiveReceiveAmount'
NonPositiveReceiveAmount = 'NonPositiveReceiveAmount',
CouldNotFetchRates = 'CouldNotFetchRates'
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
Expand All @@ -29,7 +30,8 @@ export const errorToHTTPCode: {
[QuoteErrorCode.InvalidAmount]: 400,
[QuoteErrorCode.InvalidReceiver]: 400,
[QuoteErrorCode.InactiveWalletAddress]: 400,
[QuoteErrorCode.NonPositiveReceiveAmount]: 400
[QuoteErrorCode.NonPositiveReceiveAmount]: 400,
[QuoteErrorCode.CouldNotFetchRates]: 500
}

export const errorToCode: {
Expand All @@ -39,7 +41,8 @@ export const errorToCode: {
[QuoteErrorCode.InvalidAmount]: GraphQLErrorCode.BadUserInput,
[QuoteErrorCode.InvalidReceiver]: GraphQLErrorCode.BadUserInput,
[QuoteErrorCode.InactiveWalletAddress]: GraphQLErrorCode.Inactive,
[QuoteErrorCode.NonPositiveReceiveAmount]: GraphQLErrorCode.BadUserInput
[QuoteErrorCode.NonPositiveReceiveAmount]: GraphQLErrorCode.BadUserInput,
[QuoteErrorCode.CouldNotFetchRates]: GraphQLErrorCode.InternalServerError
}

export const errorToMessage: {
Expand All @@ -49,5 +52,6 @@ export const errorToMessage: {
[QuoteErrorCode.InvalidAmount]: 'invalid amount',
[QuoteErrorCode.InvalidReceiver]: 'invalid receiver',
[QuoteErrorCode.InactiveWalletAddress]: 'inactive wallet address',
[QuoteErrorCode.NonPositiveReceiveAmount]: 'non-positive receive amount'
[QuoteErrorCode.NonPositiveReceiveAmount]: 'non-positive receive amount',
[QuoteErrorCode.CouldNotFetchRates]: 'could not convert between assets'
}
55 changes: 55 additions & 0 deletions packages/backend/src/open_payments/quote/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
PaymentMethodHandlerError,
PaymentMethodHandlerErrorCode
} from '../../payment-method/handler/errors'
import { RatesError, RatesErrorCode } from '../../rates/errors'
import { Receiver } from '../receiver/model'
import { WalletAddressService } from '../wallet_address/service'

Expand Down Expand Up @@ -553,6 +554,60 @@ describe('QuoteService', (): void => {
})
})

test('fails when exchange rates URL is not set', async (): Promise<void> => {
const receiver = await createReceiver(deps, receivingWalletAddress)
const ratesService = await deps.use('ratesService')

jest
.spyOn(ratesService, 'rates')
.mockRejectedValueOnce(
new RatesError(RatesErrorCode.MissingExchangeRatesUrl)
)

await expect(
quoteService.create({
tenantId,
walletAddressId: sendingWalletAddress.id,
receiver: receiver.incomingPayment!.id,
method: 'ilp',
debitAmount: {
value: 2n,
assetCode: sendingWalletAddress.asset.code,
assetScale: sendingWalletAddress.asset.scale
}
})
).resolves.toMatchObject({
type: QuoteErrorCode.CouldNotFetchRates
})
})

test('fails when exchange rates cannot be fetched due to a network failure', async (): Promise<void> => {
const receiver = await createReceiver(deps, receivingWalletAddress)
const ratesService = await deps.use('ratesService')

jest
.spyOn(ratesService, 'rates')
.mockRejectedValueOnce(
new RatesError(RatesErrorCode.CouldNotFetchRates)
)

await expect(
quoteService.create({
tenantId,
walletAddressId: sendingWalletAddress.id,
receiver: receiver.incomingPayment!.id,
method: 'ilp',
debitAmount: {
value: 2n,
assetCode: sendingWalletAddress.asset.code,
assetScale: sendingWalletAddress.asset.scale
}
})
).resolves.toMatchObject({
type: QuoteErrorCode.CouldNotFetchRates
})
})

test.each`
debitAmount | receiveAmount | description
${undefined} | ${undefined} | ${'with undefined debitAmount and receiveAmount'}
Expand Down
46 changes: 28 additions & 18 deletions packages/backend/src/open_payments/quote/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,30 +218,40 @@ async function createQuote(
: undefined
})
} catch (err) {
if (
err instanceof PaymentMethodHandlerError &&
err.code === PaymentMethodHandlerErrorCode.QuoteNonPositiveReceiveAmount
) {
let details = undefined

if (err.details?.minSendAmount) {
details = {
minSendAmount: {
value: 0n,
assetCode: walletAddress.asset.code,
assetScale: walletAddress.asset.scale
if (err instanceof PaymentMethodHandlerError) {
if (
err.code ===
PaymentMethodHandlerErrorCode.QuoteNonPositiveReceiveAmount
) {
let details = undefined

if (err.details?.minSendAmount) {
details = {
minSendAmount: {
value: 0n,
assetCode: walletAddress.asset.code,
assetScale: walletAddress.asset.scale
}
}
const quoteMinSendAmount = err.details.minSendAmount as bigint

details.minSendAmount.value = calculateMinSendAmount(
quoteMinSendAmount,
sendingFee
)
}
const quoteMinSendAmount = err.details.minSendAmount as bigint

details.minSendAmount.value = calculateMinSendAmount(
quoteMinSendAmount,
sendingFee
stopTimer()
return new QuoteError(
QuoteErrorCode.NonPositiveReceiveAmount,
details
)
}

stopTimer()
return new QuoteError(QuoteErrorCode.NonPositiveReceiveAmount, details)
if (err.code === PaymentMethodHandlerErrorCode.CouldNotGetRates) {
stopTimer()
return new QuoteError(QuoteErrorCode.CouldNotFetchRates)
}
}
throw err
}
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/payment-method/handler/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ interface ErrorDetails {
}

export enum PaymentMethodHandlerErrorCode {
QuoteNonPositiveReceiveAmount = 'QuoteNonPositiveReceiveAmount'
QuoteNonPositiveReceiveAmount = 'QuoteNonPositiveReceiveAmount',
CouldNotGetRates = 'CouldNotGetRates'
}

export class PaymentMethodHandlerError extends Error {
Expand Down
43 changes: 40 additions & 3 deletions packages/backend/src/payment-method/ilp/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
createTenantSettings,
exchangeRatesSetting
} from '../../tests/tenantSettings'
import { errorToMessage, RatesError, RatesErrorCode } from '../../rates/errors'

const nock = (global as unknown as { nock: typeof import('nock') }).nock

Expand Down Expand Up @@ -293,14 +294,16 @@ describe('IlpPaymentService', (): void => {
const ratesService = await deps.use('ratesService')
jest
.spyOn(ratesService, 'rates')
.mockImplementation(() => Promise.reject(new Error('fail')))
.mockImplementation(() =>
Promise.reject(new RatesError(RatesErrorCode.CouldNotFetchRates))
)

expect.assertions(4)
try {
await ilpPaymentService.getQuote({
quoteId: uuid(),
walletAddress: walletAddressMap['USD'],
receiver: await createReceiver(deps, walletAddressMap['USD']),
receiver: await createReceiver(deps, walletAddressMap['EUR']),
debitAmount: {
assetCode: 'USD',
assetScale: 2,
Expand All @@ -313,12 +316,46 @@ describe('IlpPaymentService', (): void => {
'Received error during ILP quoting'
)
expect((err as PaymentMethodHandlerError).description).toBe(
'Could not get rates from service'
errorToMessage[RatesErrorCode.CouldNotFetchRates]
)
expect((err as PaymentMethodHandlerError).retryable).toBe(false)
}
})

test('succeeds when exchange rates service fails but receiver wallet asset code matches sender', async (): Promise<void> => {
const ratesService = await deps.use('ratesService')
jest
.spyOn(ratesService, 'rates')
.mockRejectedValue(new RatesError(RatesErrorCode.CouldNotFetchRates))

const options: StartQuoteOptions = {
quoteId: uuid(),
walletAddress: walletAddressMap['USD'],
receiver: await createReceiver(deps, walletAddressMap['USD']),
debitAmount: {
assetCode: 'USD',
assetScale: 2,
value: 100n
}
}

await expect(ilpPaymentService.getQuote(options)).resolves.toMatchObject({
receiver: options.receiver,
walletAddress: options.walletAddress,
debitAmount: {
assetCode: 'USD',
assetScale: 2,
value: 100n
},
receiveAmount: {
assetCode: 'USD',
assetScale: 2,
value: 99n
},
estimatedExchangeRate: 1
})
})

test('returns all fields correctly', async (): Promise<void> => {
const ratesScope = mockRatesApi(tenantExchangeRatesUrl, () => ({}))

Expand Down
Loading
Loading