diff --git a/localenv/mock-account-servicing-entity/app/routes/rates.ts b/localenv/mock-account-servicing-entity/app/routes/rates.ts index 2e59b3e512..c841d4fdc2 100644 --- a/localenv/mock-account-servicing-entity/app/routes/rates.ts +++ b/localenv/mock-account-servicing-entity/app/routes/rates.ts @@ -1,15 +1,33 @@ import type { LoaderArgs } from '@remix-run/node' import { json } from '@remix-run/node' +interface Rates { + [currency: string]: { [currency: string]: number } +} + +const exchangeRates: Rates = { + USD: { + EUR: 1.1602, + ZAR: 17.3792 + }, + EUR: { + USD: 0.8619, + ZAR: 20.44 + }, + ZAR: { + USD: 0.0575, + EUR: 0.0489 + } +} + // eslint-disable-next-line @typescript-eslint/no-unused-vars export function loader({ request }: LoaderArgs) { + const base = new URL(request.url).searchParams.get('base') || 'USD' + return json( { - base: 'USD', - rates: { - EUR: 1.1602, - ZAR: 17.3792 - } + base, + rates: exchangeRates[base] ?? {} }, { status: 200 } ) diff --git a/packages/backend/src/open_payments/payment/outgoing/service.test.ts b/packages/backend/src/open_payments/payment/outgoing/service.test.ts index 612dd739e8..01c0b896b6 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.test.ts @@ -229,6 +229,7 @@ describe('OutgoingPaymentService', (): void => { Config.exchangeRatesUrl = 'https://test.rates' nock(Config.exchangeRatesUrl) .get('/') + .query(true) .reply(200, () => ({ base: 'USD', rates: { diff --git a/packages/backend/src/open_payments/quote/service.test.ts b/packages/backend/src/open_payments/quote/service.test.ts index 0305d88162..d683e4e4bc 100644 --- a/packages/backend/src/open_payments/quote/service.test.ts +++ b/packages/backend/src/open_payments/quote/service.test.ts @@ -72,6 +72,7 @@ describe('QuoteService', (): void => { Config.signatureSecret = SIGNATURE_SECRET nock(Config.exchangeRatesUrl) .get('/') + .query(true) .reply(200, () => ({ base: 'USD', rates: { diff --git a/packages/backend/src/open_payments/quote/service.ts b/packages/backend/src/open_payments/quote/service.ts index 772959841d..f1c5bf5e2a 100644 --- a/packages/backend/src/open_payments/quote/service.ts +++ b/packages/backend/src/open_payments/quote/service.ts @@ -221,9 +221,11 @@ export async function startQuote( deps: ServiceDependencies, options: StartQuoteOptions ): Promise { - const rates = await deps.ratesService.rates().catch((_err: Error) => { - throw new Error('missing rates') - }) + const rates = await deps.ratesService + .rates(options.receiver.assetCode) + .catch((_err: Error) => { + throw new Error('missing rates') + }) const plugin = deps.makeIlpPlugin({ sourceAccount: options.paymentPointer, diff --git a/packages/backend/src/openapi/exchange-rates.yaml b/packages/backend/src/openapi/exchange-rates.yaml index bae5cb5a70..a5bca19191 100644 --- a/packages/backend/src/openapi/exchange-rates.yaml +++ b/packages/backend/src/openapi/exchange-rates.yaml @@ -16,7 +16,14 @@ tags: description: Exchange rates paths: /: - parameters: [] + parameters: + - schema: + type: string + minLength: 1 + name: base + in: query + required: true + description: Base exchange rate Base exchange rate get: summary: Fetch exchange rates operationId: get-rates diff --git a/packages/backend/src/rates/service.ts b/packages/backend/src/rates/service.ts index ce98f266ac..04192ce7a6 100644 --- a/packages/backend/src/rates/service.ts +++ b/packages/backend/src/rates/service.ts @@ -5,7 +5,7 @@ import { convert, ConvertOptions } from './util' const REQUEST_TIMEOUT = 5_000 // millseconds export interface RatesService { - rates(): Promise + rates(baseAssetCode: string): Promise convert( opts: Omit ): Promise @@ -59,7 +59,7 @@ class RatesServiceImpl implements RatesService { if (sameCode && sameScale) return opts.sourceAmount if (sameCode) return convert({ exchangeRate: 1.0, ...opts }) - const rates = await this.sharedLoad() + const rates = await this.sharedLoad(opts.sourceAsset.code) const sourcePrice = rates[opts.sourceAsset.code] if (!sourcePrice) return ConvertError.MissingSourceAsset if (!isValidPrice(sourcePrice)) return ConvertError.InvalidSourcePrice @@ -73,11 +73,11 @@ class RatesServiceImpl implements RatesService { return convert({ exchangeRate, ...opts }) } - async rates(): Promise { - return this.sharedLoad() + async rates(baseAssetCode: string): Promise { + return this.sharedLoad(baseAssetCode) } - private sharedLoad(): Promise { + private sharedLoad(baseAssetCode: string): Promise { if (this.ratesRequest && this.ratesExpiry) { if (this.ratesExpiry < new Date()) { // Already expired: invalidate cached rates. @@ -89,7 +89,7 @@ class RatesServiceImpl implements RatesService { ) { // Expiring soon: start prefetch. if (!this.prefetchRequest) { - this.prefetchRequest = this.loadNow().finally(() => { + this.prefetchRequest = this.loadNow(baseAssetCode).finally(() => { this.prefetchRequest = undefined }) } @@ -99,7 +99,7 @@ class RatesServiceImpl implements RatesService { if (!this.ratesRequest) { this.ratesRequest = this.prefetchRequest || - this.loadNow().catch((err) => { + this.loadNow(baseAssetCode).catch((err) => { this.ratesRequest = undefined this.ratesExpiry = undefined throw err @@ -108,14 +108,16 @@ class RatesServiceImpl implements RatesService { return this.ratesRequest } - private async loadNow(): Promise { + private async loadNow(baseAssetCode: string): Promise { const url = this.deps.exchangeRatesUrl if (!url) return {} - const res = await this.axios.get(url).catch((err) => { - this.deps.logger.warn({ err: err.message }, 'price request error') - throw err - }) + const res = await this.axios + .get(url, { params: { base: baseAssetCode } }) + .catch((err) => { + this.deps.logger.warn({ err: err.message }, 'price request error') + throw err + }) const { base, rates } = res.data this.checkBaseAsset(base) diff --git a/packages/backend/src/rates/util.test.ts b/packages/backend/src/rates/util.test.ts index 807bb971b7..eb2526f074 100644 --- a/packages/backend/src/rates/util.test.ts +++ b/packages/backend/src/rates/util.test.ts @@ -1,60 +1,64 @@ import { convert, Asset } from './util' -describe('Rates util', function () { - describe('convert', function () { - it('converts same scales', () => { - // Rate > 1 - expect( - convert({ - exchangeRate: 1.5, - sourceAmount: 100n, - sourceAsset: asset(9), - destinationAsset: asset(9) - }) - ).toBe(150n) - // Rate < 1 - expect( - convert({ - exchangeRate: 0.5, - sourceAmount: 100n, - sourceAsset: asset(9), - destinationAsset: asset(9) - }) - ).toBe(50n) - // Round down - expect( - convert({ - exchangeRate: 0.5, - sourceAmount: 101n, - sourceAsset: asset(9), - destinationAsset: asset(9) - }) - ).toBe(50n) +describe('Rates util', () => { + describe('convert', () => { + describe('convert same scales', () => { + test.each` + exchangeRate | sourceAmount | assetScale | expectedResult | description + ${1.5} | ${100n} | ${9} | ${150n} | ${'exchange rate above 1'} + ${1.1602} | ${12345n} | ${2} | ${14323n} | ${'exchange rate above 1 with rounding up'} + ${1.1602} | ${10001n} | ${2} | ${11603n} | ${'exchange rate above 1 with rounding down'} + ${0.5} | ${100n} | ${9} | ${50n} | ${'exchange rate below 1'} + ${0.5} | ${101n} | ${9} | ${51n} | ${'exchange rate below 1 with rounding up'} + ${0.8611} | ${1000n} | ${2} | ${861n} | ${'exchange rate below 1 with rounding down'} + `( + '$description', + async ({ + exchangeRate, + sourceAmount, + assetScale, + expectedResult + }): Promise => { + expect( + convert({ + exchangeRate, + sourceAmount, + sourceAsset: createAsset(assetScale), + destinationAsset: createAsset(assetScale) + }) + ).toBe(expectedResult) + } + ) }) - it('converts different scales', () => { - // Scale low → high - expect( - convert({ - exchangeRate: 1.5, - sourceAmount: 100n, - sourceAsset: asset(9), - destinationAsset: asset(12) - }) - ).toBe(150_000n) - // Scale high → low - expect( - convert({ - exchangeRate: 1.5, - sourceAmount: 100_000n, - sourceAsset: asset(12), - destinationAsset: asset(9) - }) - ).toBe(150n) + describe('convert different scales', () => { + test.each` + exchangeRate | sourceAmount | sourceAssetScale | destinationAssetScale | expectedResult | description + ${1.5} | ${100n} | ${9} | ${12} | ${150_000n} | ${'convert scale from low to high'} + ${1.5} | ${100_000n} | ${12} | ${9} | ${150n} | ${'convert scale from high to low'} + `( + '$description', + async ({ + exchangeRate, + sourceAmount, + sourceAssetScale, + destinationAssetScale, + expectedResult + }): Promise => { + expect( + convert({ + exchangeRate, + sourceAmount, + sourceAsset: createAsset(sourceAssetScale), + destinationAsset: createAsset(destinationAssetScale) + }) + ).toBe(expectedResult) + } + ) }) }) }) -function asset(scale: number): Asset { - return { code: '[ignored]', scale } +function createAsset(scale: number): Asset { + return { code: 'XYZ', scale } } diff --git a/packages/backend/src/rates/util.ts b/packages/backend/src/rates/util.ts index 9d49b70f1d..5cb911ce63 100644 --- a/packages/backend/src/rates/util.ts +++ b/packages/backend/src/rates/util.ts @@ -12,11 +12,8 @@ export interface Asset { } export function convert(opts: ConvertOptions): bigint { - const maxScale = Math.max(opts.sourceAsset.scale, opts.destinationAsset.scale) - const shiftUp = 10 ** maxScale const scaleDiff = opts.destinationAsset.scale - opts.sourceAsset.scale const scaledExchangeRate = opts.exchangeRate * 10 ** scaleDiff - return ( - (opts.sourceAmount * BigInt(scaledExchangeRate * shiftUp)) / BigInt(shiftUp) - ) + + return BigInt(Math.round(Number(opts.sourceAmount) * scaledExchangeRate)) }