diff --git a/docs/connector.md b/docs/connector.md index 907bd77c2d..2dee7fad5e 100644 --- a/docs/connector.md +++ b/docs/connector.md @@ -4,7 +4,7 @@ The [`backend`](./architecture.md#backend) includes an [Interledger](./glossary. The amounts of these packets are used to update account balances in [TigerBeetle](./glossary.md#tigerbeetle). (See [Accounts and Transfers](./accounts-and-transfers.md)) -Amounts are adjusted based on the destination/outgoing account's asset and Rafiki's configured exchange [rates service](./integration.md#rates-prices). +Amounts are adjusted based on the destination/outgoing account's asset and Rafiki's configured exchange [rates service](./integration.md#exchange-rates). ## Packet Origination diff --git a/docs/deployment.md b/docs/deployment.md index 5232d3c70c..df2f599475 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -56,8 +56,8 @@ $ helm install ... | `PAYMENT_POINTER_URL` | `http://127.0.0.1:3001/.well-known/pay` | Rafiki instance internal payment pointer | | `PAYMENT_POINTER_WORKERS` | `1` | number of workers processing payment pointer requests | | `PAYMENT_POINTER_WORKER_IDLE` | `200` | milliseconds | -| `PRICES_LIFETIME` | `15_000` | milliseconds | -| `PRICES_URL` | `undefined` | endpoint on the Account Servicing Entity to request receiver fees | +| `EXCHANGE_RATES_LIFETIME` | `15_000` | milliseconds | +| `EXCHANGE_RATES_URL` | `undefined` | endpoint on the Account Servicing Entity to request receiver fees | | `PRIVATE_KEY_FILE` | `undefined` | Rafiki instance client private key | | `PUBLIC_HOST` | `http://127.0.0.1:3001` | (testing) public Host for Open Payments APIs | | `QUOTE_LIFESPAN` | `5 * 60_000` | milliseconds | diff --git a/docs/integration.md b/docs/integration.md index 219231bcbb..4b7ab9966b 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -4,7 +4,7 @@ Account Servicing Entities provide and maintain payment accounts. In order to make these accounts Interledger-enabled via Rafiki, they need to provide the following endpoints and services: -- prices (exchange rates) +- exchange rates - fees - [webhook events listener](#webhook-events-listener) - [Identity Provider](#identity-provider) @@ -15,7 +15,7 @@ Furthermore, each payment account managed by the Account Servicing Entity needs Every Interledger payment is preceded with a quote that estimates the costs for transfering value from A to B. The Account Servicing Entity may charge fees on top of that for facilitating that transfer. How they structure those fees is completely up to the Account Servicing Entity. -### Rates (Prices) +### Exchange Rates For the quoting to be successful, Rafiki needs to be provided with the current exchange rate by the Account Servicing Entity. The Account Servicing Entity needs to expose an endpoint that accepts a `GET` requests and responds as follows. @@ -27,9 +27,9 @@ For the quoting to be successful, Rafiki needs to be provided with the current e | `rates` | Object | Object containing `` pairs, e.g. `{EUR: 1.1602}` | | `rates.` | Number | exchange rate given `base` and `` | -The response status code for a successful request is a `200`. The `mock-account-servicing-entity` includes a [minimalistic example](../localenv/mock-account-servicing-entity/app/routes/prices.ts). +The response status code for a successful request is a `200`. The `mock-account-servicing-entity` includes a [minimalistic example](../localenv/mock-account-servicing-entity/app/routes/rates.ts). -The `backend` package requires an environment variable called `PRICES_URL` which MUST specify the URL of this endpoint. +The `backend` package requires an environment variable called `EXCHANGE_RATES_URL` which MUST specify the URL of this endpoint. ### Fees diff --git a/localenv/cloud-nine-wallet/docker-compose.yml b/localenv/cloud-nine-wallet/docker-compose.yml index 03247fc54e..1e66721797 100644 --- a/localenv/cloud-nine-wallet/docker-compose.yml +++ b/localenv/cloud-nine-wallet/docker-compose.yml @@ -53,7 +53,7 @@ services: PUBLIC_HOST: http://cloud-nine-wallet-backend OPEN_PAYMENTS_URL: http://cloud-nine-wallet-backend WEBHOOK_URL: http://cloud-nine-wallet/webhooks - PRICES_URL: http://cloud-nine-wallet/prices + EXCHANGE_RATES_URL: http://cloud-nine-wallet/rates REDIS_URL: redis://redis:6379/0 QUOTE_URL: http://cloud-nine-wallet/quotes PAYMENT_POINTER_URL: https://cloud-nine-wallet-backend/.well-known/pay diff --git a/localenv/happy-life-bank/docker-compose.yml b/localenv/happy-life-bank/docker-compose.yml index 7dc03b1626..124e7e7633 100644 --- a/localenv/happy-life-bank/docker-compose.yml +++ b/localenv/happy-life-bank/docker-compose.yml @@ -53,7 +53,7 @@ services: PUBLIC_HOST: http://happy-life-bank-backend WEBHOOK_URL: http://happy-life-bank/webhooks OPEN_PAYMENTS_URL: http://happy-life-bank-backend - PRICES_URL: http://happy-life-bank/prices + EXCHANGE_RATES_URL: http://happy-life-bank/rates REDIS_URL: redis://redis:6379/1 QUOTE_URL: http://happy-life-bank/quotes PAYMENT_POINTER_URL: https://happy-life-bank-backend/.well-known/pay diff --git a/localenv/mock-account-servicing-entity/app/routes/prices.ts b/localenv/mock-account-servicing-entity/app/routes/rates.ts similarity index 100% rename from localenv/mock-account-servicing-entity/app/routes/prices.ts rename to localenv/mock-account-servicing-entity/app/routes/rates.ts diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index 09ef981875..6d1075948f 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -62,8 +62,8 @@ export const Config = { ? process.env.TIGERBEETLE_REPLICA_ADDRESSES.split(',') : ['3004'], - pricesUrl: process.env.PRICES_URL, // optional - pricesLifetime: +(process.env.PRICES_LIFETIME || 15_000), + exchangeRatesUrl: process.env.EXCHANGE_RATES_URL, // optional + exchangeRatesLifetime: +(process.env.EXCHANGE_RATES_LIFETIME || 15_000), slippage: envFloat('SLIPPAGE', 0.01), quoteLifespan: envInt('QUOTE_LIFESPAN', 5 * 60_000), // milliseconds diff --git a/packages/backend/src/connector/core/factories/rafiki-services.ts b/packages/backend/src/connector/core/factories/rafiki-services.ts index a53f74a8db..870bba91ac 100644 --- a/packages/backend/src/connector/core/factories/rafiki-services.ts +++ b/packages/backend/src/connector/core/factories/rafiki-services.ts @@ -47,7 +47,7 @@ export const RafikiServicesFactory = Factory.define( })) .attr('rates', { convert: async (opts) => opts.sourceAmount, - prices: () => { + rates: () => { throw new Error('unimplemented') } }) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 963fde965a..55170764b9 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -311,8 +311,8 @@ export function initIocContainer( const config = await deps.use('config') return createRatesService({ logger: await deps.use('logger'), - pricesUrl: config.pricesUrl, - pricesLifetime: config.pricesLifetime + exchangeRatesUrl: config.exchangeRatesUrl, + exchangeRatesLifetime: config.exchangeRatesLifetime }) }) diff --git a/packages/backend/src/open_payments/payment/outgoing/errors.ts b/packages/backend/src/open_payments/payment/outgoing/errors.ts index f22e83cf7a..4530ca8ebf 100644 --- a/packages/backend/src/open_payments/payment/outgoing/errors.ts +++ b/packages/backend/src/open_payments/payment/outgoing/errors.ts @@ -48,7 +48,7 @@ export type PaymentError = LifecycleError | Pay.PaymentError export enum LifecycleError { // Rate fetch failed. - PricesUnavailable = 'PricesUnavailable', + RatesUnavailable = 'RatesUnavailable', // Edge error due to retries, partial payment, and database write errors. BadState = 'BadState', // Account asset conflicts with sendAmount asset @@ -63,7 +63,7 @@ export enum LifecycleError { const retryablePaymentErrors: { [paymentError in PaymentError]?: boolean } = { // Lifecycle errors - PricesUnavailable: true, + RatesUnavailable: true, // From @interledger/pay's PaymentError: ConnectorError: true, EstablishmentFailed: true, 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 b2827bdd11..da5cde7f75 100644 --- a/packages/backend/src/open_payments/payment/outgoing/service.test.ts +++ b/packages/backend/src/open_payments/payment/outgoing/service.test.ts @@ -225,8 +225,8 @@ describe('OutgoingPaymentService', (): void => { } beforeAll(async (): Promise => { - Config.pricesUrl = 'https://test.prices' - nock(Config.pricesUrl) + Config.exchangeRatesUrl = 'https://test.rates' + nock(Config.exchangeRatesUrl) .get('/') .reply(200, () => ({ base: 'USD', diff --git a/packages/backend/src/open_payments/quote/service.test.ts b/packages/backend/src/open_payments/quote/service.test.ts index e32edd5467..a54c91b79a 100644 --- a/packages/backend/src/open_payments/quote/service.test.ts +++ b/packages/backend/src/open_payments/quote/service.test.ts @@ -67,9 +67,9 @@ describe('QuoteService', (): void => { } beforeAll(async (): Promise => { - Config.pricesUrl = 'https://test.prices' + Config.exchangeRatesUrl = 'https://test.rates' Config.signatureSecret = SIGNATURE_SECRET - nock(Config.pricesUrl) + nock(Config.exchangeRatesUrl) .get('/') .reply(200, () => ({ base: 'USD', @@ -670,7 +670,7 @@ describe('QuoteService', (): void => { it('fails on rate service error', async (): Promise => { const ratesService = await deps.use('ratesService') jest - .spyOn(ratesService, 'prices') + .spyOn(ratesService, 'rates') .mockImplementation(() => Promise.reject(new Error('fail'))) const incomingPayment = await createIncomingPayment(deps, { paymentPointerId: receivingPaymentPointer.id @@ -682,7 +682,7 @@ describe('QuoteService', (): void => { receiver: incomingPayment.getUrl(receivingPaymentPointer), sendAmount }) - ).rejects.toThrow('missing prices') + ).rejects.toThrow('missing rates') }) }) }) diff --git a/packages/backend/src/open_payments/quote/service.ts b/packages/backend/src/open_payments/quote/service.ts index 565e79ac02..7124d10bd9 100644 --- a/packages/backend/src/open_payments/quote/service.ts +++ b/packages/backend/src/open_payments/quote/service.ts @@ -218,8 +218,8 @@ export async function startQuote( deps: ServiceDependencies, options: StartQuoteOptions ): Promise { - const prices = await deps.ratesService.prices().catch((_err: Error) => { - throw new Error('missing prices') + const rates = await deps.ratesService.rates().catch((_err: Error) => { + throw new Error('missing rates') }) const plugin = deps.makeIlpPlugin({ @@ -246,7 +246,7 @@ export async function startQuote( const quote = await Pay.startQuote({ ...quoteOptions, slippage: deps.slippage, - prices + prices: rates }).finally(() => { return Pay.closeConnection( quoteOptions.plugin, diff --git a/packages/backend/src/openapi/exchange-rates.yaml b/packages/backend/src/openapi/exchange-rates.yaml new file mode 100644 index 0000000000..bae5cb5a70 --- /dev/null +++ b/packages/backend/src/openapi/exchange-rates.yaml @@ -0,0 +1,59 @@ +openapi: 3.1.0 +info: + title: Rafiki Exchange Rates + version: '1.0' + license: + name: Apache 2.0 + identifier: Apache-2.0 + summary: Integration Endpoint Rafiki expects at the Account Servicing Entity + description: 'Rafiki calls this endpoint at the Account Servicing Entity in order to fetch current exchange rates.' + contact: + email: tech@interledger.org +servers: + - url: 'https://account-servicing-entity.com/rates' +tags: + - name: rates + description: Exchange rates +paths: + /: + parameters: [] + get: + summary: Fetch exchange rates + operationId: get-rates + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/rates' + examples: + Exchange Rates: + value: + base: 'USD' + rates: + EUR: 1.1602 + ZAR: 17.3792 + '404': + description: Not Found + description: Fetch current exchange rate pairs. + tags: + - rates +components: + schemas: + rates: + title: rates + type: object + properties: + base: + type: string + rates: + type: object + patternProperties: + ^[A-Z]{3}$: + type: number + required: + - base + - rates + securitySchemes: {} +security: [] diff --git a/packages/backend/src/rates/service.test.ts b/packages/backend/src/rates/service.test.ts index ba3d7ddc71..0658304f44 100644 --- a/packages/backend/src/rates/service.test.ts +++ b/packages/backend/src/rates/service.test.ts @@ -12,7 +12,7 @@ describe('Rates service', function () { let appContainer: TestContainer let service: RatesService let requestCount = 0 - const pricesLifetime = 100 + const exchangeRatesLifetime = 100 const koa = new Koa() koa.use(function (ctx) { requestCount++ @@ -30,8 +30,8 @@ describe('Rates service', function () { beforeAll(async (): Promise => { const config = Config - config.pricesLifetime = pricesLifetime - config.pricesUrl = 'http://127.0.0.1:3210/' + config.exchangeRatesLifetime = exchangeRatesLifetime + config.exchangeRatesUrl = 'http://127.0.0.1:3210/' deps = await initIocContainer(config) appContainer = await createTestApp(deps) jest.useFakeTimers() @@ -40,7 +40,7 @@ describe('Rates service', function () { beforeEach(async (): Promise => { // Fast-forward to reset the cache between tests. - jest.setSystemTime(Date.now() + pricesLifetime + 1) + jest.setSystemTime(Date.now() + exchangeRatesLifetime + 1) service = await deps.use('ratesService') requestCount = 0 }) @@ -157,7 +157,7 @@ describe('Rates service', function () { it('prefetches when the cached request is old', async () => { await expect(service.convert(opts)).resolves.toBe(1234n * 2n) // setup initial rate - jest.advanceTimersByTime(pricesLifetime * 0.5 + 1) + jest.advanceTimersByTime(exchangeRatesLifetime * 0.5 + 1) // ... cache isn't expired yet, but it will be soon await expect(service.convert(opts)).resolves.toBe(1234n * 2n) // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -166,7 +166,7 @@ describe('Rates service', function () { expect(requestCount).toBe(1) // Invalidate the cache. - jest.advanceTimersByTime(pricesLifetime * 0.5 + 1) + jest.advanceTimersByTime(exchangeRatesLifetime * 0.5 + 1) await expect(service.convert(opts)).resolves.toBe(1234n * 2n) // The prefetch response is promoted to the cache. // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -176,7 +176,7 @@ describe('Rates service', function () { it('cannot use an expired cache', async () => { await expect(service.convert(opts)).resolves.toBe(1234n * 2n) // setup initial rate - jest.advanceTimersByTime(pricesLifetime + 1) + jest.advanceTimersByTime(exchangeRatesLifetime + 1) await expect(service.convert(opts)).resolves.toBe(1234n * 2n) // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((service).prefetchRequest).toBeUndefined() diff --git a/packages/backend/src/rates/service.ts b/packages/backend/src/rates/service.ts index ac45601401..ce98f266ac 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 { - prices(): Promise + rates(): Promise convert( opts: Omit ): Promise @@ -13,18 +13,18 @@ export interface RatesService { interface ServiceDependencies extends BaseService { // If `url` is not set, the connector cannot convert between currencies. - pricesUrl?: string - // Duration (milliseconds) that the fetched prices are valid. - pricesLifetime: number + exchangeRatesUrl?: string + // Duration (milliseconds) that the fetched rates are valid. + exchangeRatesLifetime: number } -interface Prices { +interface Rates { [currency: string]: number } -interface PricesResponse { +interface RatesResponse { base: string - rates: Prices + rates: Rates } export enum ConvertError { @@ -40,9 +40,9 @@ export function createRatesService(deps: ServiceDependencies): RatesService { class RatesServiceImpl implements RatesService { private axios: AxiosInstance - private pricesRequest?: Promise - private pricesExpiry?: Date - private prefetchRequest?: Promise + private ratesRequest?: Promise + private ratesExpiry?: Date + private prefetchRequest?: Promise constructor(private deps: ServiceDependencies) { this.axios = Axios.create({ @@ -59,11 +59,11 @@ class RatesServiceImpl implements RatesService { if (sameCode && sameScale) return opts.sourceAmount if (sameCode) return convert({ exchangeRate: 1.0, ...opts }) - const prices = await this.sharedLoad() - const sourcePrice = prices[opts.sourceAsset.code] + const rates = await this.sharedLoad() + const sourcePrice = rates[opts.sourceAsset.code] if (!sourcePrice) return ConvertError.MissingSourceAsset if (!isValidPrice(sourcePrice)) return ConvertError.InvalidSourcePrice - const destinationPrice = prices[opts.destinationAsset.code] + const destinationPrice = rates[opts.destinationAsset.code] if (!destinationPrice) return ConvertError.MissingDestinationAsset if (!isValidPrice(destinationPrice)) return ConvertError.InvalidDestinationPrice @@ -73,19 +73,19 @@ class RatesServiceImpl implements RatesService { return convert({ exchangeRate, ...opts }) } - async prices(): Promise { + async rates(): Promise { return this.sharedLoad() } - private sharedLoad(): Promise { - if (this.pricesRequest && this.pricesExpiry) { - if (this.pricesExpiry < new Date()) { - // Already expired: invalidate cached prices. - this.pricesRequest = undefined - this.pricesExpiry = undefined + private sharedLoad(): Promise { + if (this.ratesRequest && this.ratesExpiry) { + if (this.ratesExpiry < new Date()) { + // Already expired: invalidate cached rates. + this.ratesRequest = undefined + this.ratesExpiry = undefined } else if ( - this.pricesExpiry.getTime() < - Date.now() + 0.5 * this.deps.pricesLifetime + this.ratesExpiry.getTime() < + Date.now() + 0.5 * this.deps.exchangeRatesLifetime ) { // Expiring soon: start prefetch. if (!this.prefetchRequest) { @@ -96,23 +96,23 @@ class RatesServiceImpl implements RatesService { } } - if (!this.pricesRequest) { - this.pricesRequest = + if (!this.ratesRequest) { + this.ratesRequest = this.prefetchRequest || this.loadNow().catch((err) => { - this.pricesRequest = undefined - this.pricesExpiry = undefined + this.ratesRequest = undefined + this.ratesExpiry = undefined throw err }) } - return this.pricesRequest + return this.ratesRequest } - private async loadNow(): Promise { - const url = this.deps.pricesUrl + private async loadNow(): Promise { + const url = this.deps.exchangeRatesUrl if (!url) return {} - const res = await this.axios.get(url).catch((err) => { + const res = await this.axios.get(url).catch((err) => { this.deps.logger.warn({ err: err.message }, 'price request error') throw err }) @@ -125,8 +125,8 @@ class RatesServiceImpl implements RatesService { ...(rates ? rates : {}) } - this.pricesRequest = Promise.resolve(data) - this.pricesExpiry = new Date(Date.now() + this.deps.pricesLifetime) + this.ratesRequest = Promise.resolve(data) + this.ratesExpiry = new Date(Date.now() + this.deps.exchangeRatesLifetime) return data }